From dfec0f154ba399de0ee71a79228675ea0042adb7 Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Tue, 22 Sep 2026 22:04:53 -0700 Subject: [PATCH] sheets: the column popover survives a scroll inside it, and a run no longer flags its own cells Two defects seen on the hosted service (Richard, 2026-09-22). The agent column's configuration vanished the moment its prompt textarea was scrolled: reifyui 0.8.1 closed the grid's popover on ANY scroll. The exemption for scrolls inside the box shipped in reifyui 0.13.0 on 2026-09-15, but the kits build on the 0.8 line (the 0.13 grid is the document model), so the fix is backported as reifyui 0.8.2 (maintenance-0.8 dist-tag) and the app moves to it. A red banner "claims to have run but has only one of run_id / session_id" flashed at the start of every batch: the page stamps the run id on the dispatch marker and the session id lands only when the service accepts the turn, so for a second or two a running cell holds one half. Both validators (the app's and the Skill's) now treat that as the app's own lifecycle: a session without a run is invented, a done cell needs both, a running or failed cell may still lack its session. Plugin 1.0.3 so launched Harnesses pick up the Skill's validator. Co-Authored-By: Claude Fable 5.1 --- kits/sheets/app/package-lock.json | 8 ++++---- kits/sheets/app/package.json | 2 +- kits/sheets/app/src/lib/model.js | 18 ++++++++++++------ kits/sheets/app/src/lib/model.test.mjs | 14 +++++++++++++- kits/sheets/plugin/plugin.json | 2 +- .../skills/sheet-design/validate_sheet.py | 15 ++++++++++----- 6 files changed, 41 insertions(+), 18 deletions(-) diff --git a/kits/sheets/app/package-lock.json b/kits/sheets/app/package-lock.json index f986fd2..80433d8 100644 --- a/kits/sheets/app/package-lock.json +++ b/kits/sheets/app/package-lock.json @@ -13,7 +13,7 @@ "react-dom": "^18.3.1", "react-file-icon": "^1.6.0", "react-markdown": "^9.0.1", - "reifyui": "^0.8.1", + "reifyui": "^0.8.2", "remark-gfm": "^4.0.0", "xlsx": "^0.18.5" }, @@ -2957,9 +2957,9 @@ } }, "node_modules/reifyui": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/reifyui/-/reifyui-0.8.1.tgz", - "integrity": "sha512-NoV25YFfiuyI+JP6X8HSZxx90jcm4awkaT8rDWb0MUvbdaDpcWgzL9bdDQD90hO7XZK9bYieT7PASTb4P7hkYA==", + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/reifyui/-/reifyui-0.8.2.tgz", + "integrity": "sha512-eQH6KSF4hI9bATMdJYsmLYxYq45ArRHRRwtYEgVswFdRQ93dM71wT1rbVnGNcEvtXGrZc1/hAhpyznEYyQnxQw==", "license": "MIT", "engines": { "node": ">=18" diff --git a/kits/sheets/app/package.json b/kits/sheets/app/package.json index c3ce394..e2d315c 100644 --- a/kits/sheets/app/package.json +++ b/kits/sheets/app/package.json @@ -15,7 +15,7 @@ "react-dom": "^18.3.1", "react-file-icon": "^1.6.0", "react-markdown": "^9.0.1", - "reifyui": "^0.8.1", + "reifyui": "^0.8.2", "remark-gfm": "^4.0.0", "xlsx": "^0.18.5" }, diff --git a/kits/sheets/app/src/lib/model.js b/kits/sheets/app/src/lib/model.js index 8d782d9..6aede94 100644 --- a/kits/sheets/app/src/lib/model.js +++ b/kits/sheets/app/src/lib/model.js @@ -321,12 +321,18 @@ export function validate(sheet) { if (cell.status !== undefined && !CELL_STATUS.includes(cell.status)) { err(`cells["${k}"].status`, `is ${JSON.stringify(cell.status)}`, `Use one of: ${CELL_STATUS.join(' ')}.`); } - // A cell that was never dispatched belongs to a run without having a session — that is - // what `skipped` means, and it is written by the app itself. Only a cell that claims to - // have RUN needs both halves of the reference. - const ran = ['running', 'done', 'failed'].includes(cell.status); - if (ran && Boolean(cell.run_id) !== Boolean(cell.session_id)) { - err(`cells["${k}"]`, 'claims to have run but has only one of run_id / session_id', + // The reference has two halves and the app writes them at different moments: run_id the + // instant a cell is dispatched, session_id when the service accepts the turn and names its + // session. So a `running` cell without a session is a cell whose turn is being started, a + // `failed` one without a session is a dispatch that never reached one, and a `skipped` one + // never had one. What is invented is a session with no run behind it, or a `done` cell + // missing either half. The old rule ("ran ⇒ both") flagged the app's own dispatch marker + // for a second or two at every batch start, as a red banner over a healthy run. + if (cell.session_id && !cell.run_id) { + err(`cells["${k}"]`, 'names a session_id without the run_id that produced it', + 'Both come from a real run. Delete them, or leave the cell out entirely.'); + } else if (cell.status === 'done' && (!cell.run_id || !cell.session_id)) { + err(`cells["${k}"]`, 'is done but has only one of run_id / session_id', 'Both come from a real run. Delete them, or leave the cell out entirely.'); } } diff --git a/kits/sheets/app/src/lib/model.test.mjs b/kits/sheets/app/src/lib/model.test.mjs index 587a807..f9cfacb 100644 --- a/kits/sheets/app/src/lib/model.test.mjs +++ b/kits/sheets/app/src/lib/model.test.mjs @@ -111,10 +111,22 @@ test('run state in a plain cell is refused — it claims a run that never happen assert.match(e.what, /session_id/); }); -test('an agent cell that RAN needs both run_id and session_id; a skipped one does not', () => { +test('a done cell needs both run_id and session_id; a session without a run is invented', () => { const cols = [col('c1', 'A'), agent('c2', 'B', 'do {{A}}')]; const half = sheet(cols, [{ id: 'row_1' }], { 'row_1:c2': { run_id: 'run_1', status: 'done' } }); assert.ok(validate(half).errors.some((e) => e.what.includes('only one of run_id'))); + const orphan = sheet(cols, [{ id: 'row_1' }], { 'row_1:c2': { session_id: 'hsess_1', status: 'done' } }); + assert.ok(validate(orphan).errors.some((e) => e.what.includes('without the run_id'))); + + // The app's own lifecycle: run_id lands at dispatch, session_id when the turn is accepted, so + // a running cell is briefly a run with no session; a dispatch refused before any session + // leaves a failed cell the same way. Neither is invented, and neither may show as an error + // over a healthy run (the red banner at every batch start, 2026-09-22). + const starting = sheet(cols, [{ id: 'row_1' }], { 'row_1:c2': { run_id: 'run_1', status: 'running' } }); + assert.equal(validate(starting).errors.length, 0); + const refused = sheet(cols, [{ id: 'row_1' }], + { 'row_1:c2': { run_id: 'run_1', status: 'failed', error: 'The turn could not be started.' } }); + assert.equal(validate(refused).errors.length, 0); // The app writes exactly this for a cell it never dispatched. Flagging it made the sheet page // show the person an error about its own correct output. diff --git a/kits/sheets/plugin/plugin.json b/kits/sheets/plugin/plugin.json index decf991..94aad23 100644 --- a/kits/sheets/plugin/plugin.json +++ b/kits/sheets/plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "harnessrouter-sheets", - "version": "1.0.2", + "version": "1.0.3", "description": "A spreadsheet where a column can be an agent.", "author": { "name": "HarnessRouter", diff --git a/kits/sheets/plugin/skills/sheet-design/validate_sheet.py b/kits/sheets/plugin/skills/sheet-design/validate_sheet.py index 318f789..dd8c334 100644 --- a/kits/sheets/plugin/skills/sheet-design/validate_sheet.py +++ b/kits/sheets/plugin/skills/sheet-design/validate_sheet.py @@ -186,11 +186,16 @@ def check_cells(sheet: dict, columns: list, row_ids: set) -> None: st = cell.get("status") if st is not None and st not in STATUSES: err(f"{at}.status", f"is {json.dumps(st)}", f"use one of: {' '.join(sorted(STATUSES))}") - # A `skipped` cell belongs to a run without ever having had a session; that is what - # skipped means, and the app writes it. Only a cell claiming to have RUN needs both. - if st in ("running", "done", "failed") \ - and bool(cell.get("run_id")) != bool(cell.get("session_id")): - err(at, "claims to have run but has only one of run_id / session_id", + # The reference has two halves the app writes at different moments: run_id at + # dispatch, session_id when the service accepts the turn. A running cell without a + # session is being started, a failed one without a session never reached one, a + # skipped one never had one. Invented is a session with no run, or a done cell + # missing either half. (Mirrors the app's own validator, lib/model.js.) + if cell.get("session_id") and not cell.get("run_id"): + err(at, "names a session_id without the run_id that produced it", + "both come from a real run — delete them, or leave the cell out entirely") + elif st == "done" and not (cell.get("run_id") and cell.get("session_id")): + err(at, "is done but has only one of run_id / session_id", "both come from a real run — delete them, or leave the cell out entirely") if col.get("type") == "checkbox" and "value" in cell and not isinstance(cell["value"], bool):