Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95e907a634 | ||
|
|
c38ec4e0ec | ||
|
|
ed61c85444 | ||
|
|
add5e333b9 | ||
|
|
c0b2ef3e9c | ||
|
|
a911a5f829 | ||
|
|
f6beca546a | ||
|
|
b02d31a515 | ||
|
|
5ad28a767e | ||
|
|
7f85c4b6d2 | ||
|
|
78faab1f60 | ||
|
|
f8c12dcc8b | ||
|
|
5091d38702 | ||
|
|
807c024159 | ||
|
|
dfaa469cba | ||
|
|
4c60ab8879 | ||
|
|
1d4c185d02 | ||
|
|
0e9b48fca8 | ||
|
|
f6c09c8f48 | ||
|
|
4aa270ef02 | ||
|
|
3d7c745108 | ||
|
|
ec039e1baf | ||
|
|
e63b6dcaf1 | ||
|
|
78584cd6ce | ||
|
|
6ea9a5d4e4 | ||
|
|
1a522c6602 | ||
|
|
a0a1d4f3d3 | ||
|
|
c85bfa9a85 | ||
|
|
c76f5d9cc8 | ||
|
|
275715fbc0 | ||
|
|
1ea3462edb | ||
|
|
4573bb6a2b | ||
|
|
4b67831aa8 | ||
|
|
1f1bd9e620 | ||
|
|
e1f8ff0406 | ||
|
|
cb0ed1bf60 | ||
|
|
301a751761 | ||
|
|
6c824c1e62 | ||
|
|
6140f8e087 | ||
|
|
a8cebea264 | ||
|
|
7f050a13df | ||
|
|
78e506b99d | ||
|
|
6bfe993801 | ||
|
|
092393a620 | ||
|
|
3162c99e4f | ||
|
|
1e0c1a0618 | ||
|
|
2974b173d3 | ||
|
|
90fc6061bc | ||
|
|
3c688bd126 | ||
|
|
5ba6301047 | ||
|
|
bfb0a28626 | ||
|
|
f15e0f1b0f | ||
|
|
661f1f9869 | ||
|
|
892ae96de8 | ||
|
|
f5d8ab15bc | ||
|
|
656afa07c6 | ||
|
|
280c40394f | ||
|
|
810af91368 | ||
|
|
7089285a3e | ||
|
|
4a33074b6a | ||
|
|
99de8ce1be | ||
|
|
a1dcdadde2 | ||
|
|
56bc364832 | ||
|
|
8bcf6a81b0 |
No files matched your search
@@ -83,3 +83,9 @@ DEPENDENCIES (epic + issues). -->
|
||||
|
||||
## Plain-language review notes
|
||||
<!-- Explain what Nick should inspect, approve, reject, or question. -->
|
||||
|
||||
## Research provenance
|
||||
<!-- If this PR's issue went through a research round: link the research file
|
||||
(research/issue-<N>-research.md) and state which synthesis decisions were
|
||||
adopted. If no research round: write "none" — the spec-gate will flag it. -->
|
||||
-->
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
- name: Auto-bump if the PR did not carry a version bump
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
RELEASE_SERVER_URL: ${{ gitea.server_url }}
|
||||
RELEASE_REPO: ${{ gitea.repository }}
|
||||
PR_TITLE: ${{ gitea.event.pull_request.title }}
|
||||
@@ -126,20 +126,31 @@ jobs:
|
||||
drifts = []
|
||||
for rel, pat in checks:
|
||||
if rel not in edited:
|
||||
# unchanged surface: must ALREADY carry NEW
|
||||
# unchanged surface: must ALREADY carry NEW — if it carries a
|
||||
# DIFFERENT version (base drift, e.g. pyproject at 0.49.0 while
|
||||
# master is 0.54.1), RECOVER by force-editing to NEW. The gate
|
||||
# stays: nothing commits unless every surface carries NEW.
|
||||
status, cur = api(f"/contents/{rel}?ref=master")
|
||||
text = base64.b64decode(cur.get("content", "")).decode("utf-8", "replace") if status == 200 else ""
|
||||
if status != 200:
|
||||
drifts.append(f"{rel}: unreadable ({status})")
|
||||
continue
|
||||
text = base64.b64decode(cur.get("content", "")).decode("utf-8", "replace")
|
||||
hits = re.findall(pat, text)
|
||||
if NEW not in hits:
|
||||
drifts.append(f"{rel}: unchanged but does not carry {NEW} (found {sorted(set(hits))})")
|
||||
fixed = re.sub(pat, lambda m: m.group(0).replace(m.group(1), NEW), text, count=1)
|
||||
if fixed != text:
|
||||
edited[rel] = (fixed, cur["sha"])
|
||||
print(f" RECOVERED {rel}: base drift {sorted(set(hits))} -> {NEW}")
|
||||
else:
|
||||
drifts.append(f"{rel}: pattern present but unfixable (found {sorted(set(hits))})")
|
||||
else:
|
||||
hits = re.findall(pat, edited[rel][0])
|
||||
if NEW not in hits:
|
||||
drifts.append(f"{rel}: edited content does not carry {NEW}")
|
||||
if drifts:
|
||||
print("VERSION-SURFACE DRIFT — bump aborted, nothing committed:", file=sys.stderr)
|
||||
print("VERSION-SURFACE DRIFT — bump aborted, nothing committed:")
|
||||
for d in drifts:
|
||||
print(f" - {d}", file=sys.stderr)
|
||||
print(f" - {d}")
|
||||
sys.exit(1)
|
||||
|
||||
# Commit each surface DIRECTLY to master (bookkeeping, not reviewable code)
|
||||
@@ -162,7 +173,7 @@ jobs:
|
||||
PYBUMP
|
||||
- name: Create release from merged pull request
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
PR_TITLE: ${{ gitea.event.pull_request.title }}
|
||||
PR_BODY: ${{ gitea.event.pull_request.body }}
|
||||
RELEASE_API_URL: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases
|
||||
|
||||
@@ -80,6 +80,10 @@ jobs:
|
||||
python3 -m venv .test-venv
|
||||
.test-venv/bin/pip install --disable-pip-version-check -r requirements-ci.txt
|
||||
.test-venv/bin/pip install --disable-pip-version-check -e . --no-deps
|
||||
- name: Umbrella impact gate (propagation contract)
|
||||
run: |
|
||||
python3 scripts/umbrella_impact.py check \
|
||||
--base "$(git merge-base origin/master HEAD)" --head HEAD
|
||||
- name: Test with coverage (global floor + changed-lines ratchet)
|
||||
run: |
|
||||
# #149 coverage ratchet — both floors live in .coveragerc; raise,
|
||||
|
||||
@@ -21,19 +21,28 @@ name: Research stage (headless)
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_number:
|
||||
description: "Issue to research"
|
||||
required: true
|
||||
round:
|
||||
description: "Research round number"
|
||||
required: false
|
||||
default: "1"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: research-stage-${{ gitea.event.issue.number }}
|
||||
group: research-stage-${{ gitea.event.inputs.issue_number || gitea.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
research:
|
||||
runs-on: rune-ci-desktop
|
||||
if: gitea.event.label.name == 'research-pending'
|
||||
if: ${{ gitea.event.label.name == 'research-pending' || gitea.event_name == 'workflow_dispatch' }}
|
||||
steps:
|
||||
- name: Check out master (research reads the codebase, not a PR)
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
@@ -47,56 +56,76 @@ jobs:
|
||||
HOME: /var/lib/gitea-desktop-runner
|
||||
XDG_CONFIG_HOME: /home/nick/.config
|
||||
XDG_DATA_HOME: /var/lib/gitea-desktop-runner/.local-share-mimo
|
||||
ISSUE_NUMBER: ${{ gitea.event.issue.number }}
|
||||
ISSUE_TITLE: ${{ gitea.event.issue.title }}
|
||||
ISSUE_BODY: ${{ gitea.event.issue.body }}
|
||||
ISSUE_NUMBER: ${{ gitea.event.inputs.issue_number || gitea.event.issue.number }}
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
SERVER: ${{ gitea.server_url }}
|
||||
run: |
|
||||
# Prompt = governed repo template (prompts/research-round.md) with
|
||||
# placeholders substituted. The template is the TUI-callback seam:
|
||||
# future L3 (Rune TUI) owns/overrides it via reviewed diffs.
|
||||
# Fetch issue title/body from the API (dispatch events carry no issue
|
||||
# payload). Deterministic, no scraping.
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
import json, os, urllib.request
|
||||
token = os.environ["GITEA_TOKEN"]
|
||||
base = os.environ["SERVER"].rstrip("/") + "/api/v1/repos/conductor/rune"
|
||||
n = os.environ["ISSUE_NUMBER"]
|
||||
req = urllib.request.Request(
|
||||
f"{base}/issues/{n}",
|
||||
headers={"Authorization": "token " + token})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
issue = json.loads(r.read())
|
||||
tpl = open("prompts/research-round.md").read()
|
||||
out = (tpl.replace("{{ISSUE_NUMBER}}", os.environ["ISSUE_NUMBER"])
|
||||
.replace("{{ISSUE_TITLE}}", os.environ["ISSUE_TITLE"])
|
||||
.replace("{{ISSUE_BODY}}", os.environ["ISSUE_BODY"] or "(empty body)"))
|
||||
out = (tpl.replace("{{ISSUE_NUMBER}}", n)
|
||||
.replace("{{ISSUE_TITLE}}", issue.get("title", ""))
|
||||
.replace("{{ISSUE_BODY}}", issue.get("body") or "(empty body)"))
|
||||
open("/tmp/research_prompt.txt", "w").write(out)
|
||||
print(f"prompt built: {len(out)} chars from prompts/research-round.md")
|
||||
print(f"prompt built: {len(out)} chars (issue #{n}: {issue.get('title','')[:50]})")
|
||||
PY
|
||||
|
||||
/var/lib/gitea-desktop-runner/bin/mimo run \
|
||||
--agent build \
|
||||
-m "Verboo Code/glm-5.3-flash" \
|
||||
--dangerously-skip-permissions \
|
||||
--format json \
|
||||
"$(cat /tmp/research_prompt.txt)" \
|
||||
> /tmp/research_events.jsonl 2>/tmp/research_err.log
|
||||
> /tmp/research_events.jsonl 2>/tmp/research_err.log || {
|
||||
echo "mimo exited nonzero — stderr:"
|
||||
cat /tmp/research_err.log | tail -10
|
||||
exit 1
|
||||
}
|
||||
echo "mimo events: $(wc -l < /tmp/research_events.jsonl) lines"
|
||||
|
||||
# Extract final assistant text (fail-loud: no swallow)
|
||||
# Extract ALL text parts (the agent may produce multiple text blocks
|
||||
# across tool-use rounds; the last contiguous block is the synthesis)
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
text = ""
|
||||
texts = []
|
||||
try:
|
||||
for line in open("/tmp/research_events.jsonl"):
|
||||
try:
|
||||
ev = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if ev.get("type") == "assistant" and isinstance(ev.get("message", {}).get("content"), list):
|
||||
for part in ev["message"]["content"]:
|
||||
if part.get("type") == "text":
|
||||
text = part.get("text", text)
|
||||
if ev.get("type") == "text":
|
||||
t = ev.get("part", {}).get("text", "")
|
||||
if t.strip():
|
||||
texts.append(t)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
if not text.strip():
|
||||
raise SystemExit("research stage: agent produced NO text — infrastructure or model failure (fail-loud)")
|
||||
if not texts:
|
||||
raise SystemExit(
|
||||
f"research stage: agent produced NO text in {sum(1 for _ in open('/tmp/research_events.jsonl'))} events "
|
||||
"— infrastructure or model failure (fail-loud)"
|
||||
)
|
||||
# Join all text blocks (the agent's progressive output IS the round)
|
||||
text = "\n\n".join(texts)
|
||||
open("/tmp/research_round.md", "w").write(text)
|
||||
print(f"research text: {len(text)} chars from {len(texts)} blocks")
|
||||
PY
|
||||
|
||||
- name: File research as comment + versioned research file
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
SERVER: ${{ gitea.server_url }}
|
||||
ISSUE_NUMBER: ${{ gitea.event.issue.number }}
|
||||
ISSUE_NUMBER: ${{ gitea.event.inputs.issue_number || gitea.event.issue.number }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json, os, subprocess, urllib.request
|
||||
@@ -119,6 +148,14 @@ jobs:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
print(f"research comment posted: {r.status}")
|
||||
|
||||
# Disposition routing (§14): PROCEED → research-clear fires via the
|
||||
# marker comment. MORE_RESEARCH → strip the synthesis from the
|
||||
# comment is NOT needed (the gate keys on all 4 phase markers, which
|
||||
# are present); instead the run_agent_stage result records the
|
||||
# disposition and the ROUND increments on the next dispatch. The
|
||||
# scheduled sweep re-dispatches when research-pending persists and
|
||||
# no stage is running (§19 reconciliation path).
|
||||
|
||||
# 2. Research file committed straight to master by the robot identity
|
||||
subprocess.run(["git", "config", "user.name", "conductor"], check=True)
|
||||
subprocess.run(["git", "config", "user.email", "nick@thingscouldgetdicey.online"], check=True)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Rune
|
||||
|
||||
**Current release: v0.54.1**
|
||||
**Current release: v0.54.8**
|
||||
|
||||
**Rune is a persistent personal agent designed to accumulate experience, develop reusable competence, and remain recognisably the same agent even when its underlying foundation model changes.**
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rune"
|
||||
version = "0.54.1"
|
||||
version = "0.54.8"
|
||||
description = "Rune — a stateful coding assistant with persistent memory, orchestration, and TUI"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
Research round for #245 (4-phase, agent-generated, filed by research stage per #204; comment on the issue is the research-clear trigger)
|
||||
|
||||
---
|
||||
|
||||
No skill is a better fit than the task's own mandated 4-phase format, so I'll execute the research round directly. Starting with INWARD codebase analysis.
|
||||
|
||||
INWARD picture is forming. Let me check the research artifact gate contract and one prior round for format conventions, then move to OUTWARD.
|
||||
|
||||
INWARD is complete — I have the full map of reactive usage. Now OUTWARD: fetching live sources on Textual 8.x reactive performance.
|
||||
|
||||
Reactivity guide and changelog retrieved (repo pins textual==8.2.8, which is the latest release). Two more targeted fetches: the `reactive`/`var` API reference and the `textual-speedups` / `Widget.BLANK` optimization notes.
|
||||
|
||||
All four phases are researched — I have the inward map, four live-fetched sources (one dead link substituted), and I can now evaluate candidates and write the deliverable file.
|
||||
|
||||
Both artifacts are complete: the round is written to `research/issue-245-research.md` (verified: exact required header line, phase markers on their own lines for the research-clear gate) and nothing was committed or pushed. Here is the same content as my final message:
|
||||
|
||||
---
|
||||
|
||||
Research round for #245 — E2E acceptance: research round on Textual 8.x reactive performance patterns (4-phase, agent-generated)
|
||||
|
||||
ISSUE: Disposable acceptance issue for the Forge Cognitive Research Loop (P0.8).
|
||||
Topic: Textual 8.x reactive performance patterns as applied to the Rune TUI.
|
||||
Repo pin: `textual==8.2.8` (requirements-ci.txt:105, pyproject.toml:12) — 8.2.8 is the current upstream release as of this run, so the codebase is on the latest stable.
|
||||
|
||||
## INWARD
|
||||
|
||||
The TUI surface lives in `src/rune/` (governed: every TUI surface must be registered per henxels AGENTS.md:27; rune modules need tests or a canary per AGENTS.md:28).
|
||||
|
||||
Reactive inventory — RuneApp (`src/rune/rune_tui.py:1520-1527`), 8 class-level reactives:
|
||||
|
||||
| Reactive | Line | Visual? | Watcher? |
|
||||
|---|---|---|---|
|
||||
| `mode` | rune_tui.py:1520 | yes (status bar) | none defined |
|
||||
| `project` | rune_tui.py:1521 | yes (status bar) | none |
|
||||
| `model` | rune_tui.py:1522 | yes (status bar) | none |
|
||||
| `task_width` | rune_tui.py:1523 | yes (pane split) | none |
|
||||
| `_last_ctrl_c` | rune_tui.py:1524 | no | none |
|
||||
| `planning_active` | rune_tui.py:1525 | no | none |
|
||||
| `plan_threshold` | rune_tui.py:1526 | no | none |
|
||||
| `work_pane_visible` | rune_tui.py:1527 | yes (pane display) | none |
|
||||
|
||||
Key findings:
|
||||
|
||||
1. **No `watch_*` methods exist anywhere in the TUI** (grep across rune_tui.py, chat_bubble.py, message_widgets.py: zero `def watch_`). Instead, every reactive change is followed by a manual propagation call:
|
||||
- `action_resize_left/right` set `self.task_width` (rune_tui.py:4043, 4048) then call `_apply_resize()` (rune_tui.py:4050-4057) which mutates `task.styles.width`/`chat.styles.width`. The reactive assignment *also* triggers Textual's smart refresh on the App. Result: two refresh paths per keypress, one of them a full-App refresh for a change only two children care about.
|
||||
- `action_toggle_work_pane` sets `self.work_pane_visible` (rune_tui.py:4030) then calls `_apply_work_pane_visibility()` (rune_tui.py:4035-4040).
|
||||
- `_update_status()` (rune_tui.py:4067-4072) manually copies `mode`/`model`/`project` into `StatusBar` attributes — state duplication with manual fan-out.
|
||||
|
||||
2. **Non-visual state declared as `reactive()`**: `_last_ctrl_c`, `planning_active`, `plan_threshold` (rune_tui.py:1524-1526) never affect rendering, yet every assignment triggers App-level refresh machinery. `planning_active` is flipped per message cycle (rune_tui.py:2039, 2099, 2236).
|
||||
|
||||
3. **Unbounded widget growth in chat logs**: `ChannelView.add_message` (rune_tui.py:1198-1210) mounts a new `MessageBox` (chat_bubble.py:279, a `Horizontal` container wrapping a `ChatBubble(Static)`) per message into `VerticalScroll` and calls `scroll_end` — no cap. Contrast: `WorkPane` is explicitly bounded at `_WORK_PANE_MAX_ENTRIES = 120` (rune_tui.py:1262), and `RichLog` (used for command results, rune_tui.py:552) supports `max_lines` upstream. A long session therefore accumulates thousands of mounted widgets; Textual renders/keeps strips for all of them.
|
||||
|
||||
4. **Double render/compose pattern**: `MessageBox.compose()` (chat_bubble.py:305-309) plus `ChatBubble` markup-string `render()` — consistent with Textual guidance, no issue. ThinkingBubble toggling does header `update()` correctly (chat_bubble.py:260-271).
|
||||
|
||||
5. **Existing contracts this touches**:
|
||||
- `tests/test_message_widgets.py` covers `ToolCallWidget`; `tests/test_version_footer.py:23` imports `textual.widgets.Static`.
|
||||
- `src/rune/canary_tui_drift.py` is the TUI-wiring canary (registered per henxels AGENTS.md:15).
|
||||
- `scripts/check_dep_lock.py` + `tests/test_check_dep_lock.py:27` pin the textual version; dependency changes are gated.
|
||||
- `config/umbrellas.json` — umbrella impact map must stay truthful (AGENTS.md:20); any `src/rune/rune_tui.py` behavioral change propagates through the umbrella gate.
|
||||
- Prior in-repo research `research/textual-craft/findings/F1.md` (reactivity best practices), `F2.md` (render/compose), `F3.md` (async/performance) already documented the Textual guides — this round's job is to verify those claims live against 8.x and find what is BETTER than the obvious.
|
||||
|
||||
INWARD claim IDs (for causal links):
|
||||
- **I1**: 3 of 8 RuneApp reactives are non-visual and needlessly trigger refresh (rune_tui.py:1524-1526).
|
||||
- **I2**: Reactive state changes are propagated by manual imperative calls, not watch methods, causing double refresh paths (rune_tui.py:4030-4072).
|
||||
- **I3**: Chat message widgets grow unbounded while WorkPane and RichLog demonstrate the bounded pattern exists in-repo (rune_tui.py:1198, 1262, 552).
|
||||
|
||||
## OUTWARD
|
||||
|
||||
Live sources retrieved this run (tool = webfetch over HTTPS):
|
||||
|
||||
- **S1** — Textual Reactivity guide (current docs): https://textual.textualize.io/guide/reactivity/
|
||||
- learned: smart refresh coalesces multiple reactive changes into one refresh; assignment of an unchanged value does NOT refresh; `var()` gives reactive superpowers with no refresh/layout; `watch_<name>` methods fire only on real change; `set_reactive()` avoids watcher invocation pre-mount; `recompose=True` is "slightly less efficient than a simple refresh, and best avoided if you need to update rapidly or you have many child widgets"; compute methods are re-run when ANY reactive on the object changes — keep them cheap.
|
||||
- **S2** — Textual CHANGELOG (main branch, covers 8.0.0 → 8.2.8): https://raw.githubusercontent.com/Textualize/textual/main/CHANGELOG.md
|
||||
- learned (performance-relevant, all present in 8.2.8): 8.1.0 replaced DOM circular references with weak references to improve GC times (#6410); 7.1.0 added `Widget.BLANK` to optimize rendering of large widgets/scrolling containers; 7.0.3 fixed a performance issue with large scrollable containers (#6317); 8.2.3 moved resize handling to a timer to reduce window-resize lag (#6471); 6.2.0 reduced layout operations per screen update (#6108); 5.2.0 added the faster undocumented `stream` layout (#6013).
|
||||
- **S3** — textual-speedups (Rust optional accelerators): https://github.com/willmcgugan/textual-speedups
|
||||
- NOTE: the canonical URL https://github.com/Textualize/textual-speedups returned HTTP 404 this run — substituted with the maintainer's repo above (the changelog S2 links it as "willmcgugan/textual-speedups", confirming the substitution).
|
||||
- learned: auto-imported by Textual ≥5.0.0 when installed (`TEXTUAL_SPEEDUPS=0` disables); accelerates `Offset`/`Size`/`Region`/`Spacing` geometry math used heavily during layout; upstream README itself says "unless your Textual app is particularly complex, you might not even notice a difference".
|
||||
- **S4** — RichLog widget reference: https://textual.textualize.io/widgets/rich_log/
|
||||
- learned: `max_lines` (a `var`) bounds retained lines; `write()` defers rendering until size is known; `auto_scroll` is a var; the simpler `Log` widget is recommended for plain text. Confirms the bounded-retention idiom that WorkPane (I3) already mirrors.
|
||||
|
||||
Search queries recorded: "Textual 8.x reactive performance patterns", "textual changelog 8.x performance GC scrollable", "textual-speedups repository", "RichLog max_lines widget reference".
|
||||
|
||||
OUTWARD claims (IDs for causal links):
|
||||
- **O1** (S1): `var()` is the sanctioned way to hold non-visual reactive state without refresh cost; watch methods are the sanctioned way to propagate reactive state — supporting I1 and I2.
|
||||
- **O2** (S2): Textual 8.2.x already contains the major framework-side performance work (weak-ref GC, large-container fix, Widget.BLANK) — framework-level wins are already banked at our pin; remaining wins are application-level.
|
||||
- **O3** (S3): The only extra framework accelerator (textual-speedups) is optional, geometry-only, and self-described as possibly imperceptible — weak candidate for our lock-file-gated dependency policy.
|
||||
- **O4** (S4): Bounded log retention (`max_lines`-style) is a first-class upstream idiom — supporting I3.
|
||||
|
||||
Answer to the driving question "is there anything BETTER than the obvious approach?": **No framework-level mechanism beats the obvious ones.** 8.2.8 already includes the GC/layout/container work; the leverage is in how RuneApp *uses* reactives (var vs reactive, watchers vs manual fan-out, bounded widget population), not in adding anything.
|
||||
|
||||
## INNOVATION
|
||||
|
||||
Candidates, each evaluated for effort / risk / fit:
|
||||
|
||||
- **C1 — Convert non-visual reactives to `var()`.**
|
||||
derived_from: [I1, O1]
|
||||
proposal: `from textual.reactive import reactive, var`; change `_last_ctrl_c`, `planning_active`, `plan_threshold` to `var(...)` (rune_tui.py:1524-1526). Watchers and validation still work; refresh machinery no longer engaged.
|
||||
expected_gain: removes App-level refresh on every planning-state flip and every Ctrl+C (frequent paths — rune_tui.py:2039/2099/2236). Low absolute cost each, but on the hottest assignment paths.
|
||||
effort: trivial (~5 lines). risk: negligible (no rendering depends on them). fit: exact match to documented upstream idiom. **ADOPT.**
|
||||
|
||||
- **C2 — Replace manual fan-out with watch methods.**
|
||||
derived_from: [I2, O1]
|
||||
proposal: add `watch_task_width` (applies the pane split), `watch_work_pane_visible` (applies display policy), and `watch_mode`/`watch_model`/`watch_project` (update StatusBar) in RuneApp; delete the explicit `_apply_resize()`/`_apply_work_pane_visibility()`/`_update_status()` call-sites that merely duplicate reactive propagation (keep the helpers as the watcher bodies; they contain try/except guards worth keeping). Caveats from S1 to respect: watchers fire only on real change (fine — all call-sites are real changes); DOM-querying watchers must not run pre-mount, so keep the existing try/except (or use `set_reactive()` in `__init__` for the saved-model assignment at rune_tui.py:1545-1547, which currently *does* invoke watcher machinery pre-mount — with watchers added this becomes load-bearing).
|
||||
expected_gain: single refresh path per change; eliminates full-App refresh where a two-child update suffices; aligns with upstream idiom so future Textual refresh optimizations apply automatically.
|
||||
effort: small (~40 lines, no behavior change). risk: low — must verify the pre-mount `self.model = saved` path and `mode` changes during startup. fit: high. **ADOPT.**
|
||||
|
||||
- **C3 — Bound chat-log widget population.**
|
||||
derived_from: [I3, O4]
|
||||
proposal: in `ChannelView.add_message` (rune_tui.py:1198), after mounting, prune the oldest `MessageBox` children of `#log-<channel>` beyond a cap (e.g. 200), mirroring `_WORK_PANE_MAX_ENTRIES` (rune_tui.py:1262) and RichLog's `max_lines` (S4). The `self.messages` projection list (rune_tui.py:1207-1210) stays the durable display record; only mounted widgets are bounded. This is application-level compensation because the bubbles must remain interactive widgets (expand/collapse ThinkingBubble, CopyMenu — chat_bubble.py:16, 260-271), so a RichLog/Line-API rewrite (virtual rendering) is not viable without losing interactivity.
|
||||
expected_gain: prevents multi-thousand-widget DOM in long sessions — the single largest real-world latency factor for scroll/refresh in this app per S2's large-container history (7.0.3) and per WorkPane's existing bounded design.
|
||||
effort: medium (~25 lines + tests). risk: low-medium — must not prune the conversation ledger (durable state lives in ConversationStateRepository per rune_tui.py:1172-1191 comment), only DOM children; scroll position after prune needs care (only prune when scrolled to bottom, or accept anchor jump).
|
||||
fit: high. **ADOPT.**
|
||||
|
||||
- **C4 — Add `textual-speedups` optional dependency.**
|
||||
derived_from: [O3] — **REJECTED.** Gain unquantified and self-described as possibly imperceptible; adds a native/Rust wheel to `requirements-ci.txt` whose lock is contract-gated (tests/test_check_dep_lock.py); upstream auto-import means we already get it for free wherever users install it. Honesty over novelty: no measured problem it solves here.
|
||||
|
||||
- **C5 — Rewrite chat log on RichLog / Line API for virtual rendering.**
|
||||
derived_from: [I3, S4] — **REJECTED:** loses interactive per-message widgets (collapse/expand, copy menu, tool decision buttons in ToolCallWidget, message_widgets.py:17-35). C3 captures the win at far lower risk.
|
||||
|
||||
Adopted set: C1 + C2 + C3. (All three derive from OUTWARD evidence, satisfying the causality gate.)
|
||||
|
||||
## SYNTHESIS
|
||||
|
||||
Decision: **PROCEED.** Adopt reactive hygiene (C1, C2) and bounded log population (C3); reject framework additions (C4, C5).
|
||||
evidence_used: [I1, I2, I3, O1, O2, O3, O4]
|
||||
blocking_unknowns: none blocking. (Non-blocking: precise perf delta of C1/C2 is expected small; C3's win is structural, not micro-benchmarked. If the implementer wants a number, a before/after widget-count assertion in tests is the cheap proxy.)
|
||||
kill_test: if adding `watch_task_width` breaks the pre-mount saved-model path or the startup `mode` assignment such that behavior changes, fall back to keeping manual calls AND make the reactives `var()`/watch-free as in C1 — the round still delivers value.
|
||||
feasibility: high — all changes confined to `src/rune/rune_tui.py` (+ one test file). No new dependencies, no schema changes.
|
||||
|
||||
### BUILD INSTRUCTIONS (ordered, self-contained for the implementer)
|
||||
|
||||
1. Branch `perf/245-reactive-hygiene` off master. Do NOT bump the textual pin — 8.2.8 already contains the framework-side work (O2).
|
||||
|
||||
2. RED-FIRST tests — write these before touching source (bug-fix discharge contract, AGENTS.md:53-60; they will fail on current code):
|
||||
a. tests/test_rune_tui_reactive.py (new file; follow the import style of tests/test_message_widgets.py):
|
||||
- test_non_visual_state_is_var: assert that `_last_ctrl_c`, `planning_active`, `plan_threshold` on RuneApp are instances of `textual.reactive.var` (and that `mode`, `model`, `project`, `task_width`, `work_pane_visible` remain `reactive`). RED on current code.
|
||||
- test_watch_methods_propagate: run RuneApp under `textual.testing` AppTest / Pilot; set `app.task_width = 65` and assert `#task-channel` style width became "65%" and `#chat-channel` "35%" WITHOUT calling `_apply_resize` (monkeypatch it to raise, or assert it was not invoked). RED on current code.
|
||||
- test_status_bar_tracks_reactives: set `app.model = "x"`; assert StatusBar reflects it without `_update_status()` being called. RED.
|
||||
- test_chat_log_bounded: mount RuneApp with Pilot, push > cap (e.g. 220) messages via `channel.add_message(...)`, assert `len(log.children) <= cap` while `len(channel.messages)` keeps growing (projection survives pruning). RED on current code.
|
||||
b. Update tests/test_henxels_contract.py expectations only if the contract check counts rune_tui.py reactives (it does not today — verify with `henxels explain src/rune/rune_tui.py` before writing).
|
||||
|
||||
3. Implement (src/rune/rune_tui.py only):
|
||||
- Step 1 (C1): rune_tui.py:108 import gains `var`; lines 1524-1526 become `var(0.0)`, `var(False)`, `var(0)`. Keep the trailing comments.
|
||||
- Step 2 (C2): convert `_apply_resize`, `_apply_work_pane_visibility` bodies into `watch_task_width(self, width)` / `watch_work_pane_visible(self, visible)`; add `watch_mode`, `watch_model`, `watch_project` calling the StatusBar update (replace `_update_status` body or keep it as a shared helper called by the three watchers). Delete now-redundant explicit call-sites at rune_tui.py:4043/4048 (keep `self.task_width = ...` clamping — or move clamping into `validate_task_width`), rune_tui.py:4030, and wherever `_update_status` is invoked. In `__init__` (rune_tui.py:1545-1547), replace `self.model = saved` with `self.set_reactive(RuneApp.model, saved)` to avoid firing the new watcher pre-mount (S1).
|
||||
- Step 3 (C3): add `_CHAT_LOG_MAX_WIDGETS = 200` beside `_WORK_PANE_MAX_ENTRIES` (rune_tui.py:1262). In `ChannelView.add_message` (rune_tui.py:1198-1210), after `log.mount(bubble)`, prune: `while len(log.children) > _CHAT_LOG_MAX_WIDGETS: await-removed first child` — in sync context use `log.remove_children`-style batch removal of `log.children[:overflow]` via `self.call_after_refresh` or mount-then-remove pattern; preserve `scroll_end(animate=False)` semantics. Never touch `self.messages`.
|
||||
- Step 4: update `src/rune/canary_tui_drift.py` wiring tests if they introspect rune_tui source for these symbols (they introspect by `inspect.getsource` — check section 8 pattern, canary_tui_drift.py:88-96, and extend rather than break).
|
||||
|
||||
4. Gates that apply:
|
||||
- Full suite + ruff + pyright ratchets + canaries + henxels (bug-fix contract re-entry, AGENTS.md:56-58).
|
||||
- Umbrella propagation: `config/umbrellas.json` — rune_tui.py is mapped; run the umbrella impact check (scripts/umbrella_impact.py); do not add phantom entries.
|
||||
- research-clear/pipeline gate: this round's file lands via the research stage; the build PR references #245.
|
||||
- dep lock: untouched (no dependency changes — C4 rejected).
|
||||
|
||||
5. PR strategy: one PR, title `perf(tui): reactive hygiene + bounded chat log (#245)`, body citing this research file path `research/issue-245-research.md`, the three adopted candidates and two rejections with reasons. Single commit-set reviewable in one pass; no release-bump trigger unless auto-version policy requires it for src changes.
|
||||
|
||||
6. Verification: run the new test file plus `pytest tests/test_message_widgets.py tests/test_version_footer.py` and a manual `python -m src.rune` smoke (bubble render, Ctrl+1/2/3, Ctrl+left/right, /commands) confirming no visual regressions.
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""research_artifact.py — structured research-round schema + validation (P0.4).
|
||||
|
||||
Per the Forge Cognitive Research Loop handoff §8: machine-readable research
|
||||
round with causal evidence links. Phases are FROZEN artifacts — the later
|
||||
phase's input is the persisted prior artifact (§7: "the later phase must not
|
||||
be able to pretend it saw research that the pipeline never recorded").
|
||||
|
||||
Schema (minimal, causally sufficient):
|
||||
issue, round, receipt (from run_agent_stage.build_receipt),
|
||||
phases: {inward: {claims: [{id, claim, evidence[]}]},
|
||||
outward: {sources: [{id, url, title, retrieved_at, tool,
|
||||
content_hash, learned}],
|
||||
claims: [{id, claim, supports[]}]},
|
||||
innovation: {candidates: [{id, proposal, derived_from[],
|
||||
expected_gain, risks[]}]},
|
||||
synthesis: {decision, evidence_used[], blocking_unknowns[],
|
||||
feasibility, kill_test,
|
||||
disposition: PROCEED|MORE_RESEARCH}}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def validate_outward(outward: dict) -> list[str]:
|
||||
"""§9: OUTWARD valid only with real discovery — ≥2 sources, each with
|
||||
url + tool receipt + learned note; ≥1 search query recorded."""
|
||||
errs = []
|
||||
sources = outward.get("sources", [])
|
||||
if len(sources) < 2:
|
||||
errs.append(f"OUTWARD: {len(sources)} sources < 2 (real discovery required)")
|
||||
for s_ in sources:
|
||||
for field in ("id", "url", "tool", "learned"):
|
||||
if not s_.get(field):
|
||||
errs.append(f"OUTWARD source {s_.get('id', '?')}: missing {field}")
|
||||
if not re.match(r"https?://", s_.get("url", "")):
|
||||
errs.append(f"OUTWARD source {s_.get('id', '?')}: url not http(s)")
|
||||
if not outward.get("queries"):
|
||||
errs.append("OUTWARD: no search queries recorded (discovery, not recall)")
|
||||
return errs
|
||||
|
||||
|
||||
def validate_causal_links(artifact: dict) -> list[str]:
|
||||
"""§11/§12: innovation candidates derive_from prior claims; synthesis
|
||||
decisions cite evidence_used. At least one candidate depends on OUTWARD
|
||||
evidence unless synthesis records 'no external mechanism relevant'."""
|
||||
errs = []
|
||||
phases = artifact.get("phases", {})
|
||||
inward_ids = {c["id"] for c in phases.get("inward", {}).get("claims", [])}
|
||||
outward_ids = {c["id"] for c in phases.get("outward", {}).get("claims", [])}
|
||||
candidates = phases.get("innovation", {}).get("candidates", [])
|
||||
synthesis = phases.get("synthesis", {})
|
||||
known = inward_ids | outward_ids
|
||||
for c in candidates:
|
||||
refs = set(c.get("derived_from", []))
|
||||
if not refs:
|
||||
errs.append(f"INNOVATION {c.get('id', '?')}: no derived_from (decorative, not causal)")
|
||||
continue
|
||||
if not refs & known:
|
||||
errs.append(f"INNOVATION {c.get('id', '?')}: derived_from references unknown claims {sorted(refs - known)}")
|
||||
outward_dependent = any(set(c.get("derived_from", [])) & outward_ids for c in candidates)
|
||||
if not outward_dependent and "no external mechanism relevant" not in synthesis.get("decision", ""):
|
||||
errs.append("no INNOVATION candidate derives from OUTWARD evidence — research was decorative")
|
||||
if not synthesis.get("evidence_used"):
|
||||
errs.append("SYNTHESIS: evidence_used empty — decisions untraceable")
|
||||
if synthesis.get("disposition") == "MORE_RESEARCH" and not synthesis.get("blocking_unknowns"):
|
||||
errs.append("MORE_RESEARCH disposition without named blocking_unknowns")
|
||||
return errs
|
||||
|
||||
|
||||
def validate(artifact: dict) -> list[str]:
|
||||
errs = validate_outward(artifact.get("phases", {}).get("outward", {}))
|
||||
errs += validate_causal_links(artifact)
|
||||
if artifact.get("phases", {}).get("synthesis", {}).get("disposition") not in ("PROCEED", "MORE_RESEARCH"):
|
||||
errs.append("synthesis.disposition must be PROCEED or MORE_RESEARCH")
|
||||
return errs
|
||||
|
||||
|
||||
def main_with(argv: list[str]) -> int:
|
||||
artifact = json.loads(Path(argv[1]).read_text())
|
||||
errs = validate(artifact)
|
||||
if errs:
|
||||
print("research artifact INVALID:")
|
||||
for e in errs:
|
||||
print(f" - {e}")
|
||||
return 1
|
||||
print("research artifact VALID (outward discovery + causal links verified)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main_with(sys.argv))
|
||||
@@ -133,8 +133,13 @@ def compute_targets(issues: list[dict], api) -> list[dict]:
|
||||
if unsatisfied:
|
||||
continue
|
||||
_, comments = api.get(f"/issues/{i['number']}/comments")
|
||||
if has_research_round(comments if isinstance(comments, list) else []):
|
||||
continue
|
||||
comments = comments if isinstance(comments, list) else []
|
||||
if has_research_round(comments):
|
||||
# A round exists — re-queue ONLY if its disposition says MORE_RESEARCH
|
||||
# (§14: the loop continues). Latest round wins (comments are append-only).
|
||||
more = any(re.search(r"Disposition:\s*MORE_RESEARCH", c.get("body", ""), re.IGNORECASE) for c in comments)
|
||||
if not more:
|
||||
continue
|
||||
targets.append(i)
|
||||
return targets
|
||||
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
This is the main entry point for the Rune program.
|
||||
"""
|
||||
|
||||
__version__ = "0.54.1"
|
||||
__version__ = "0.54.8"
|
||||
@@ -252,7 +252,7 @@ def main() -> int:
|
||||
)
|
||||
check(
|
||||
"Rune minor version advertises provisioning and restore verification",
|
||||
'__version__ = "0.54.1"' in rune_version,
|
||||
'__version__ = "0.54.8"' in rune_version,
|
||||
failures,
|
||||
)
|
||||
# #169 policy update: auto-bump on merge is sanctioned (Nick directive:
|
||||
@@ -270,7 +270,7 @@ def main() -> int:
|
||||
)
|
||||
check(
|
||||
"release job explicitly binds its Gitea token",
|
||||
"GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}" in versioning,
|
||||
"GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}" in versioning,
|
||||
failures,
|
||||
)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ mode system, subject header, shared memory and identity.
|
||||
|
||||
This is not an interface. This is how I see, speak, and interact with the world.
|
||||
|
||||
Version: v0.54.1
|
||||
Version: v0.54.8
|
||||
Features: 41 (24 original + 17 new native commands)
|
||||
|
||||
Usage:
|
||||
@@ -1923,7 +1923,7 @@ class RuneApp(App):
|
||||
# Show version info
|
||||
self.chat_channel.add_message(
|
||||
"system",
|
||||
f"[dim]Rune v0.54.1 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
f"[dim]Rune v0.54.8 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
)
|
||||
|
||||
# Auto-show night mode suggestion if evening/night hours
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Red-first tests for scripts/research_artifact.py — P0.4 causal validation.
|
||||
|
||||
Per the Forge Cognitive Research Loop handoff:
|
||||
§9 OUTWARD = real discovery (≥2 sources with url+tool+learned, ≥1 query)
|
||||
§11 INNOVATION candidates MUST derive_from prior claims; ≥1 from OUTWARD
|
||||
§12 SYNTHESIS decisions cite evidence_used
|
||||
§14 MORE_RESEARCH requires named blocking_unknowns
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SPEC = importlib.util.spec_from_file_location("research_artifact", ROOT / "scripts" / "research_artifact.py")
|
||||
ra = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(ra)
|
||||
|
||||
|
||||
def make_artifact(**overrides):
|
||||
base = {
|
||||
"issue": 42,
|
||||
"round": 1,
|
||||
"receipt": {"search_or_fetch_calls": 3, "distinct_web_domains": 2},
|
||||
"phases": {
|
||||
"inward": {
|
||||
"claims": [{"id": "I1", "claim": "store uses FTS5", "evidence": ["src/rune/memory_store.py:86"]}]
|
||||
},
|
||||
"outward": {
|
||||
"queries": ["python sqlite fts5 hybrid search"],
|
||||
"sources": [
|
||||
{
|
||||
"id": "O1",
|
||||
"url": "https://sqlite.org/fts5.html",
|
||||
"title": "FTS5",
|
||||
"retrieved_at": "2026-09-08T00:00Z",
|
||||
"tool": "webfetch",
|
||||
"content_hash": "abc",
|
||||
"learned": "FTS5 supports external content tables",
|
||||
},
|
||||
{
|
||||
"id": "O2",
|
||||
"url": "https://github.com/asg017/sqlite-vec",
|
||||
"title": "sqlite-vec",
|
||||
"retrieved_at": "2026-09-08T00:00Z",
|
||||
"tool": "webfetch",
|
||||
"content_hash": "def",
|
||||
"learned": "vec0 virtual tables for embedding search",
|
||||
},
|
||||
],
|
||||
"claims": [{"id": "O-C1", "claim": "hybrid FTS+vec is viable", "supports": ["O1", "O2"]}],
|
||||
},
|
||||
"innovation": {
|
||||
"candidates": [
|
||||
{
|
||||
"id": "C1",
|
||||
"proposal": "hybrid recall via FTS+vec union",
|
||||
"derived_from": ["I1", "O-C1"],
|
||||
"expected_gain": "better recall",
|
||||
"risks": ["latency"],
|
||||
},
|
||||
]
|
||||
},
|
||||
"synthesis": {
|
||||
"decision": "adopt hybrid recall",
|
||||
"evidence_used": ["I1", "O-C1", "C1"],
|
||||
"blocking_unknowns": [],
|
||||
"feasibility": "mechanism proven by O2",
|
||||
"kill_test": "recall returns >0 rows on seeded store",
|
||||
"disposition": "PROCEED",
|
||||
},
|
||||
},
|
||||
}
|
||||
for k, v in overrides.items():
|
||||
if k == "phases":
|
||||
base["phases"].update(v)
|
||||
else:
|
||||
base[k] = v
|
||||
return base
|
||||
|
||||
|
||||
def test_valid_artifact_passes():
|
||||
assert ra.validate(make_artifact()) == []
|
||||
|
||||
|
||||
def test_outward_single_source_fails():
|
||||
a = make_artifact(
|
||||
phases={
|
||||
"outward": {
|
||||
"queries": ["q"],
|
||||
"sources": [{"id": "O1", "url": "https://x", "tool": "webfetch", "learned": "y"}],
|
||||
"claims": [],
|
||||
}
|
||||
}
|
||||
)
|
||||
errs = ra.validate(a)
|
||||
assert any("sources < 2" in e for e in errs)
|
||||
|
||||
|
||||
def test_outward_missing_learned_fails():
|
||||
a = make_artifact(
|
||||
phases={
|
||||
"outward": {
|
||||
"queries": ["q"],
|
||||
"sources": [
|
||||
{"id": "O1", "url": "https://x", "tool": "webfetch", "learned": ""},
|
||||
{"id": "O2", "url": "https://y", "tool": "webfetch", "learned": "z"},
|
||||
],
|
||||
"claims": [],
|
||||
}
|
||||
}
|
||||
)
|
||||
errs = ra.validate(a)
|
||||
assert any("missing learned" in e for e in errs)
|
||||
|
||||
|
||||
def test_outward_no_queries_fails():
|
||||
a = make_artifact(
|
||||
phases={
|
||||
"outward": {
|
||||
"queries": [],
|
||||
"sources": [
|
||||
{"id": "O1", "url": "https://x", "tool": "webfetch", "learned": "y"},
|
||||
{"id": "O2", "url": "https://y", "tool": "webfetch", "learned": "z"},
|
||||
],
|
||||
"claims": [],
|
||||
}
|
||||
}
|
||||
)
|
||||
errs = ra.validate(a)
|
||||
assert any("no search queries" in e for e in errs)
|
||||
|
||||
|
||||
def test_innovation_without_derived_from_fails():
|
||||
a = make_artifact(
|
||||
phases={
|
||||
"innovation": {
|
||||
"candidates": [{"id": "C1", "proposal": "x", "derived_from": [], "expected_gain": "", "risks": []}]
|
||||
}
|
||||
}
|
||||
)
|
||||
errs = ra.validate(a)
|
||||
assert any("no derived_from" in e for e in errs)
|
||||
|
||||
|
||||
def test_innovation_unknown_claim_reference_fails():
|
||||
a = make_artifact(
|
||||
phases={
|
||||
"innovation": {
|
||||
"candidates": [
|
||||
{"id": "C1", "proposal": "x", "derived_from": ["I999"], "expected_gain": "", "risks": []}
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
errs = ra.validate(a)
|
||||
assert any("unknown claims" in e for e in errs)
|
||||
|
||||
|
||||
def test_no_outward_derived_candidate_fails_unless_exempt():
|
||||
a = make_artifact(
|
||||
phases={
|
||||
"innovation": {
|
||||
"candidates": [{"id": "C1", "proposal": "x", "derived_from": ["I1"], "expected_gain": "", "risks": []}]
|
||||
}
|
||||
}
|
||||
)
|
||||
errs = ra.validate(a)
|
||||
assert any("decorative" in e for e in errs)
|
||||
# exemption: synthesis explicitly records no external mechanism relevant
|
||||
a["phases"]["synthesis"]["decision"] = "internal-only; no external mechanism relevant"
|
||||
errs2 = ra.validate(a)
|
||||
assert not any("decorative" in e for e in errs2)
|
||||
|
||||
|
||||
def test_synthesis_empty_evidence_used_fails():
|
||||
a = make_artifact(
|
||||
phases={
|
||||
"synthesis": {
|
||||
"decision": "x",
|
||||
"evidence_used": [],
|
||||
"blocking_unknowns": [],
|
||||
"feasibility": "",
|
||||
"kill_test": "",
|
||||
"disposition": "PROCEED",
|
||||
}
|
||||
}
|
||||
)
|
||||
errs = ra.validate(a)
|
||||
assert any("evidence_used empty" in e for e in errs)
|
||||
|
||||
|
||||
def test_more_research_without_unknowns_fails():
|
||||
a = make_artifact(
|
||||
phases={
|
||||
"synthesis": {
|
||||
"decision": "x",
|
||||
"evidence_used": ["I1"],
|
||||
"blocking_unknowns": [],
|
||||
"feasibility": "",
|
||||
"kill_test": "",
|
||||
"disposition": "MORE_RESEARCH",
|
||||
}
|
||||
}
|
||||
)
|
||||
errs = ra.validate(a)
|
||||
assert any("blocking_unknowns" in e for e in errs)
|
||||
|
||||
|
||||
def test_invalid_disposition_fails():
|
||||
a = make_artifact(
|
||||
phases={
|
||||
"synthesis": {
|
||||
"decision": "x",
|
||||
"evidence_used": ["I1"],
|
||||
"blocking_unknowns": [],
|
||||
"feasibility": "",
|
||||
"kill_test": "",
|
||||
"disposition": "VIBES",
|
||||
}
|
||||
}
|
||||
)
|
||||
errs = ra.validate(a)
|
||||
assert any("disposition" in e for e in errs)
|
||||
@@ -174,3 +174,19 @@ def test_skips_issues_with_open_pr(forge, monkeypatch):
|
||||
monkeypatch.setattr(rs, "open_pr_issue_refs", lambda api: {30})
|
||||
targets = rs.compute_targets(forge.issues, forge)
|
||||
assert [t["number"] for t in targets] == [31]
|
||||
|
||||
|
||||
def test_more_research_round_not_treated_as_done(forge):
|
||||
"""P0.6 §14: a completed round with MORE_RESEARCH disposition must NOT be
|
||||
treated as done (the backfill sweep must not skip it as 'already
|
||||
researched' if the label was lost — the stage re-dispatches)."""
|
||||
forge.issues = [_issue(32)] # label lost (event delivery failure)
|
||||
forge.comments = {32: [{"body": RESEARCH_COMMENT + "\n\nDisposition: MORE_RESEARCH (blocking: U1 perf numbers)"}]}
|
||||
targets = rs.compute_targets(forge.issues, forge)
|
||||
assert [t["number"] for t in targets] == [32], "MORE_RESEARCH stays eligible for re-dispatch"
|
||||
|
||||
|
||||
def test_proceed_round_not_requeued(forge):
|
||||
forge.issues = [_issue(33)] # NOT labeled (research-clear already ran)
|
||||
forge.comments = {33: [{"body": RESEARCH_COMMENT + "\n\nDisposition: PROCEED"}]}
|
||||
assert rs.compute_targets(forge.issues, forge) == []
|
||||
Reference in new issue
Block a user