Skip to content

v2.2.28 — attribution reads the executor's exit code, and two published verdicts are corrected (2026-09-19)

Latest

Choose a tag to compare

@github-actions github-actions released this 19 Sep 12:46
· 1 commit to main since this release

v2.2.28 — attribution reads the executor's exit code, and two published verdicts are corrected (2026-09-19)

  • Cold-start assets now read their facts instead of memorising them, and the tick's timeout
    budget got a gate
    (AGENTS.md + .qoder/rules/ig-marker-triage.md +
    src/infinigrow/engine/tick.py + tests/test_tick_timeout_budget.py + the handoff redaction
    plan). The repo's own onboarding docs pinned a HEAD tag and a "no open work" verdict — and they
    were already stale on arrival (they said v2.2.25; HEAD was two releases ahead). Anything that
    moves every commit is now a command to run (git describe --tags, git status --short,
    infinigrow version --check), not a number copied into prose. A new constant
    MAX_EXECUTOR_CALLS_PER_TICK (= 2: the tick call plus the org-session call) feeds a budget gate:
    executor_timeout_s × calls must stay under the scheduled task's -ExecutionTimeLimit, or
    Windows kills a run mid-flight and leaves a half-written ledger — a relation the local
    IG_EXECUTOR_TIMEOUT_S=600 (~20 min against a 30 min wall) makes real but nothing watched. The
    gate reads the config default, not the machine env, so it is deterministic. Separately, the
    handoff notes carry hundreds of machine-specific absolute paths; they are not edited here
    (changing another session's handoff assets is out of scope) — a redaction plan is filed as a
    pending decision instead.

  • The observation surface became a live reading, and the trigger it documents has fired
    (src/infinigrow/engine/subject.py + engine/tick.py + tests/test_subject_composition.py
    + both growth-subject.md). The doc promised that the day evidence files crowded the
    observation surface, a "movable" criterion would be designed first. That day is measured, not
    guessed: observation_composition groups the 20-slot file window by top-level directory and the
    tick trace now prints it, so "who is eating the slots" is a computed reading instead of a manual
    read of a trace file. At tick 745 the live subject reads app 18 / journal 2 (app evidence
    files, ever-newest by mtime and deliberately never rotated, crowd the growing journal out). A
    copy-based comparison confirms the cause: pruning app/ to its 4 newest files flips the same
    window to journal 16 / app 4 — it is app's mtime ordering, not the size 20. Conclusion:
    needs a decision, not a bigger constant.
    The observation caps (10 / 20) are untouched this
    round; widening them would let still more evidence files crowd out journal/ without adding one
    accountable quantity. tests/test_subject_composition.py pins grouping, that the archived subtree
    never counts, and the reverse case where app squeezes journal out of the window entirely. A latent
    locale bug surfaced on the way: tests/test_release_tooling.py ran the extraction tool as a
    subprocess and decoded its output with the machine locale, so the em dash in a CHANGELOG heading
    crashed the Windows GBK reader thread (stdout came back None); the call now pins encoding="utf-8".

  • The write surface got direct tests, the weak assertions became real, and five dead
    definitions were retired
    (tests/test_store_gates.py + tests/test_rules.py +
    tests/test_version.py + tests/test_release_tooling.py + .github/workflows/ci.yml +
    src/infinigrow/rules/static_scan.py + core/paths.py / engine/sprout_queue.py /
    ledger/rotation.py + both superseded.md + both markers.md). ledger/store.py is the
    only place allowed to touch disk, yet its eight write gates had no test of their own — they
    were only covered indirectly when the engine happened to pass through them, so a broken
    marker gate or a clobbered exclusive-create could sit unnoticed. tests/test_store_gates.py
    pins each gate (append/guard, bad-line tolerance surfaced not hidden, require_within
    rejecting escapes and symlink hops, create_exclusive losing the race without clobbering,
    write_work_file's marker gate refusing a shrink, atomic replace leaving no .part,
    move_file writing the archive before unlinking the source). Three assertions that had
    drifted weak are now strong: every rule must actually PASS on the clean repo (not just
    "return without raising"); the current version must appear as a real ## v<version> section
    heading with the top section the newest (a mere cross-reference no longer counts); the
    release tool is checked by exit code (rc 0 present, rc 1 missing tag, rc 2 bad usage),
    not just by an exception's message. A new check enforces every git tag has a CHANGELOG
    section
    — which needs CI to fetch tags, so ci.yml adds fetch-depth: 0 (a shallow
    checkout lists no tags and would make the check vacuous). The R2 prompt↔code rule no longer
    skips when the prompts are empty: empty/missing prompt text is now itself a failure (the old
    prompt_text and ... guard let an empty prompts dir pass silently), with a reverse case in
    selftest and a floor test pinning MIN_SYNC_TERMS at 17 terms ⊆ the coverage table. Five
    never-called definitions (ENV_SUBJECT_ROOT, all_dirs, to_records, ledger_sizes,
    total_archived_lines) — measured 2026-09-19 to have zero callers repo-wide — were retired
    through the superseded four-step (a new row S10, old rows kept, scan + pytest green).
    No limit constant moved.

  • S9's retired exemption stopped masquerading as live in the Chinese comments
    (src/infinigrow/cli.py + engine/sprout_queue.py + engine/domain_saturation.py +
    tests/test_sprout_queue.py + both superseded.md). The S9 retirement guard watched only
    the English identifier long_task, so five comments could still describe the exemption as
    current behaviour in Chinese — cli.py ("长任务芽豁免连领上限"), the queue docstring
    ("连领上限 3 拍(长任务芽豁免)"), and three in domain_saturation.py ("…且非长任务") —
    right under a superseded row that says there is no exemption. Measured 2026-09-19: the word
    长任务 appeared nowhere in the mechanism itself, only in prose, and the code has no such
    condition. The five comments now say plainly there is no exemption; the return guard was
    widened to reject the Chinese name 长任务 anywhere in src//prompts/ (not just the English
    usage), with a reverse case proving both spellings are caught while a backtick prose mention
    of long_task as history stays allowed. Both superseded.md S9 rows record the widened
    guard. No constant or behaviour changed — this is wording, not a reopening of K16/S9.

  • The read side of the CLI entered CI, and so did Python 3.13 (.github/workflows/ci.yml +
    tests/test_cli_surface.py + both READMEs + CONTRIBUTING.md). Until now CI exercised only the
    write path (dry-run/tick/gardener/org-status); status, redemption --json, rotate,
    rotate --search and version --check had never run on any CI, even though the docs publish them
    as the reading surface — and rotate --search, the documented answer to "moved but still
    findable", had zero cases. The new step reuses the cold-start temp state root (${{ runner.temp }})
    so it runs against ledgers that have content, on both ubuntu and windows; tests/test_cli_surface.py
    pins the hit and miss directions of --search (hit must report file:line, miss must report 0 and
    not crash), the exit-code contract of version --check offline (behind → 3, equal → 0), clean
    stderr on the success path, and that the Python versions the docs list are exactly the CI matrix.
    3.13 joins the matrix on measurement, not hope: this machine's interpreter is 3.13.7 and the
    whole gate battery (pytest / ruff / scan R1–R10 / selftest / prompt-sync / privacy / abs-path) is
    green on it. End-to-end on a copy of the live state root (the live one untouched): rotate --max-bytes 4000 moved 1,290 + 3,462 + 1,233 lines into archive/, then rotate --search
    reported 20 hits in the archived files (rc=0) and 0 hits for an absent needle (rc=0); the config
    source line printed defaults、caller with IG_EXECUTOR/IG_EXECUTOR_TIMEOUT_S cleared.

  • The project privacy layer is now provable on CI instead of permanently skipped
    (tests/data/privacy-deny-sample.txt + tests/data/privacy-sentinel/leaky-example.md +
    tests/test_privacy_deny_layer.py + .github/workflows/ci.yml + both privacy.md files).
    The real deny list is machine-specific and stays out of the repository — that is by design and was
    not changed — but as a consequence test_repo_is_free_of_project_deny_hits skipped on every CI
    run, so nothing anywhere proved that --deny still bites; a broken loader would have read as
    green. The fix is not to delete the skip (it is genuine environment adaptation): a committed
    deny sample made of invented terms, plus a sentinel carrying the payload it must catch, now prove
    both directions on ubuntu and windows — deny given → the three rules report hits and exit 1;
    payload removed → zero hits and exit 0 — and the sentinel is itself checked to contribute no
    generic-layer hits, so shipping it cannot dirty the repo-wide gate.

  • Bilingual parity moved from headings to body text, and the marker numbering got a registry
    (tests/test_docs_bilingual.py + docs/markers.md + docs/zh/markers.md +
    tools/check_markers.py + the four English sections that were empty). The old check compared
    only the heading skeleton, so a section could exist in English and be almost hollow: measured
    today, growth-subject.md §4 was 18 Chinese lines against 3 English ones, §7 11 against 5, and
    running.md §1/§2 13 against 3 and 16 against 5 — which is exactly how the N48 series, the
    "19 characters" measurement, SUBJECT_DIR_LIMIT and the K12/A17 timeout relation ended up
    readable in one language only. The new checks are positional and mechanical: every section with
    ≥8 Chinese lines must have at least half that body in English, and number-carrying anchors must
    appear in both books (measured, not copied: the brief's "mechanism.md is 240 lines short /
    tick.md 189" no longer holds — the English mechanism book is 398 lines against 377, and there is
    no docs/tick.md at all, so those two figures are corrected here rather than repeated). The
    registry answers a second gap: ids like K15 or N48-2 were cited but never defined anywhere.
    Every id referenced in docs/ now has a row (id | proposition | where it is enforced | status),
    with three rulings stated honestly: K15 is registered as undecided (the ordering key is not
    touched), G7 as one id, two meanings (the capability-library ETA and rotation), and
    H8/O5 as orphans — H8 duplicates Q13/A10 and O5 has zero references repo-wide, so
    neither may be cited as if it were an in-force decision. tools/check_markers.py prints the same
    diff the tests enforce. No limit constant moved.

  • Configuration defaults now have one source, and three numeric gates became readable
    (src/infinigrow/core/config.py + tests/test_config_single_source.py + both mechanism.md
    + prompts/org-session.md + both READMEs). Settings used to spell its 24 defaults out twice —
    once in _FIELDS, once in the class body — so editing one silently separated the runtime default
    from the table humans read (and the table is the published wording). The class body now derives
    from the table, and the test proves the derivation rather than restating it: it fails if a literal
    default reappears. The other half of the gap was invisible thresholds: DEFAULT_ZERO_GAP (10
    rows), TRACE_GATE_FILES (the "used this tick" gate read "the last few traces" — a number that
    existed only in code) and review_frozen(max_relight=…) (never mentioned anywhere outside the
    source, including prompts/, although it is the queue the session competes for). Each now carries
    its own number in both originals, in prompts/org-session.md, and in a case that interpolates the
    constant — change the constant without the docs and it goes red, in either language. Behaviour
    cases cover four knobs rather than their parsing: cooldown minutes flip the same ledger from
    "cooling" to "gap backfill", tick_minutes reaches the scheduler as seconds and rejects
    non-positive values loudly, executor_timeout_s really kills a slow executor, and
    rotate_keep_files decides how many traces stay while the excess moves (never deletes).
    No threshold value changed (docs/versioning.md: this is provenance, not mechanism).

  • tools/split_monolith.py is registered as parked rather than newly tested (CONTRIBUTING.md).
    It is analysis-only and its --src input — the v1 monolith — is not in this repository, so a
    behavioural case would mean committing a fabricated monolith and testing the fixture; CI keeps
    covering its --help syntax, and the note records what to add (--table) if a real input appears.

  • Failure attribution gained a second executor-side test (engine/reconcile.py
    executor_tick_failures + cli.py redemption + both mechanism.md files +
    tests/test_redemption_report.py). The "dropped on the executor side" bucket used to recognise
    only the literal "output was not parsed" marker in the trace, which the adapter writes when
    parsing fails. When the executor exits non-zero it writes no such marker, so that failure was
    filed against the subject: at tick 723 state/executor.jsonl has rc=1/usage=unknown while
    the outcome row for the same tick is verifiable=true, redeemed=false — the age rule called
    it "genuinely not done". The bucket now also fires when every tick-class call of that tick
    exited non-zero or timed out, with the disqualifiers kept strict (one rc=0 attempt in the same
    tick, or only org-session calls, is not an excuse). Measured on the live ledgers at tick 730:
    723 moves buckets, 499/521 stay put, 676/679 keep the marker route, and the numerator and
    denominator are untouched
    (318 sampled / 311 redeemed / 7 failed / 363 unverifiable).

  • "Proposal went stale" is now stated as what it is — a mechanical proxy, pending proof: age
    ≥ 30 ticks proves the premise was old, never that the proposal's content expired, and the bucket
    is only reached once both executor-side tests fail to hold. The wording was already hedged in the
    Chinese original; the English table now carries the same grade instead of a bare "proxy".

  • Two published verdicts in the evidence section were re-run and are publicly corrected
    (both mechanism.md files + tests/test_evidence_snapshot.py +
    tests/data/redemption-tick-00731.json). v2.2.27 quoted a tick-624 snapshot as if it were a
    conclusion; the ledgers only grow, so by tick 731 the readings are 682 led rows / 319 checkable
    samples
    / 312 redeemed / 363 unverifiable (53% of leads) and the two claims failed a live
    re-run: (1) the lib* origin was reported as no samples, yet the bucket holds
    8 checkable samples since tick 654 (654, 656–662) — and n=8 buys a sample count and its ticks,
    not a 1.00 verdict; (2) the misses were reported as 4 rows with 0 "genuinely not done",
    yet there are 7, splitting 3 / 2 / 2 under the attribution above. The "no case to extrapolate"
    discipline is kept and extended: it binds the n=8 bucket exactly as it bound the 0-case one.
    Every figure in the section is now checked against one redemption --json snapshot by a test, so
    corrupting a number in the docs turns CI red; the README's test-size figure moved to ≈6,600
    because the new cases are 236 lines.