test(ingestion): real-host bare-output re-check and the review-round contract

A metadata-disabled save, held open behind the fixture gate after
SaveImage, waits unlinked while its run is open and links once the run's
outputs are recorded. The fixture's monitor route lists the waiting
files. TESTING.md covers the history re-check, the read index and what
the repair tool's --apply does.
This commit is contained in:
Vito Sansevero
2026-10-25 14:26:53 -07:00
parent 417a05a2db
commit 16e88342ad
4 changed files with 92 additions and 14 deletions
+48 -12
View File
@@ -3869,7 +3869,9 @@ fallback (`_get_fallback_prompt` is gone) and no unproven tracker entry.
counts and tracks nothing for the positive side, logs at info (no "Could not
save or track" warning), and marks its run key empty, so no earlier entry of
that key can be used and a batch item keeps its queue place
(`PromptTracker.mark_empty`). The queue hook counts nothing for it either.
(`PromptTracker.mark_empty`). The queue hook counts nothing for it either. A
save or tracking failure of a positive node marks its run key the same way, so
an older entry of that key never stands in for the failed run.
- **Run keys** (`utils/run_keys.py`): sha256 of a prompt node plus its whole
upstream closure (`class_type` and `inputs`) in the API graph. Nodes register
tracker entries under it; the monitor computes it from the image's embedded
@@ -3881,14 +3883,36 @@ fallback (`_get_fallback_prompt` is gone) and no unproven tracker entry.
snapshot's entry itself is used only when its run id is known
(`comfy_execution.utils.get_executing_context`) and that run registered one
entry for the key, which covers a cached re-run (sampler seed changed) without
applying a batch's last item to every image. More than one positive
PromptManager node in the image graph: never linked from the tracker.
applying a batch's last item to every image. When the key's latest entry is a
placeholder the image stays unlinked without consulting history (the same
rule as a popped placeholder). More than one positive PromptManager node in
the image graph, or any positive node without a key: never linked from the
tracker. Links are followed exactly as ComfyUI's `is_link` defines them (a
`str` node id and an `int` or `float` slot in a two-item list). Equal keys do
not always mean equal text: the key can't see `IS_CHANGED`, so an upstream
node that forces re-runs gives two runs one key; that is why the run named by
the snapshot is required as well. A file event delivered after the next run
already re-registered the key is the one remaining exposure (see the ledger).
- **Recorded history** (`database/run_attribution.py`): a file is attributed when
exactly one recorded run reported its output location (root key, subfolder,
name), its mtime lies within that run (dequeue to terminal or callback, +-2 s),
and every positive context names the same stored prompt (the live
`positive_prompt_id` hint). The monitor tries it last; the scan tries it when the
#170 browser-parser floor finds nothing, and never creates a prompt from it.
#170 browser-parser floor finds nothing, and also for files with no prompt
metadata at all (`--disable-metadata`, savers that embed none); it never
creates a prompt. The lookup uses `idx_history_read_execution_run_outputs`
(`filename, subfolder, root_key, path_style`), added by
`migrate_execution_history`; the output schema check accepts exactly that
optional index and still refuses any other. An older build refuses a library
that has it, so downgrading after this change needs `DROP INDEX
idx_history_read_execution_run_outputs` first.
- **After the run ends.** An image with no run keys (no embedded API graph) that
nothing identifies waits for its run: when the run's outputs are recorded
(`write_execution_outputs_for_finish` notifies `subscribe_outputs_sealed`
listeners after commit), each waiting image the run reported gets exactly one
history-only re-check on the monitor's worker. At most 1000 images wait, each
for at most an hour; shutdown forgets them. The image is registered before the
first history check, so a run sealed in between is not missed.
- **Blank positive.** A resolved pair whose stored positive is empty (the #342
image with its Pair execution record) is `unresolved` for the scan and for
`ingest_resolved_pair` (it used to raise in `_save_pair`).
@@ -3901,23 +3925,35 @@ Tests: `tests/test_prompt_attribution.py` (nodes, tracker, monitor, run keys),
`tests/comfyui/image-attribution.spec.mjs` (both renderers): a typed Pair image
links, a Pair image whose positive is only linked prepend text from an unknown
node stays unlinked with nothing stored, an unknown node feeding `text` links
through its run key, and its cached re-run with a new sampler seed links again.
The managed host config disables monitoring, so that case starts and stops the
plugin's monitor through the owned fixture route `/pm_execution_fixture/monitor`.
through its run key, and its cached re-run with a new sampler seed links again
(the host's `execution_cached` event names the PromptManager node, under the
fixture's Classic cache). A second case saves with metadata disabled and holds
the run open behind the fixture gate after SaveImage: the output is waiting and
unlinked while the run is open, and links to the stored prompt once the run's
outputs are recorded. The managed host config disables monitoring, so these
cases start and stop the plugin's monitor through the owned fixture route
`/pm_execution_fixture/monitor` (its GET lists the waiting files).
On origin/main the prepend image was linked to an unrelated prompt.
Repair: `tools/review_image_links.py <db> --output-dir <ComfyUI output> --ids
<ids>` (or `--prompt <id>`) judges links by these rules (`keep`, `relink`,
`unlink`, `conflict`, `missing`) on a backup copy and changes nothing.
`--apply` writes `relink`/`unlink` only for ids named with `--ids`, after saving
`<db>.pre-relink_<stamp>`. Queue-linked batch images can't be proven after the
fact and read `unlink`, so review before applying. Stop ComfyUI first.
`<db>.pre-relink_<stamp>`, and only while each image is still linked to the
prompt it was judged on (otherwise it is counted as skipped). `relink` points the
link record at the other prompt and drops its pair links. `unlink` removes only
the image's link record (its `generated_images` row, the metadata snapshot stored
in it and its pair links): that is how the app represents an unlinked image, the
same as a file the scan could not attribute, so the file stays on disk and in the
gallery as unlinked, and its favorite and tags (kept by file) are untouched. Every
run prints this. Queue-linked batch images can't be proven after the fact and read
`unlink`, so review before applying. Stop ComfyUI first.
## Durable execution ledger
`npm run --prefix tools/vendor test:executions` runs 50 real execution cases
(25 each in Classic and Nodes 2.0, retaining all 48 prior execution cases and adding
the #342 image-attribution case),
`npm run --prefix tools/vendor test:executions` runs 52 real execution cases
(26 each in Classic and Nodes 2.0, retaining all 48 prior execution cases and adding
the two #342 image-attribution cases),
alongside the existing 80 stock host cases (the original 78 plus the native
editor host case) and six pack compatibility cases. Run
these suites serially because they share report cleanup and owned host resources. Use the same pinned disposable `PM_COMFY_ROOT`,
+10
View File
@@ -1,5 +1,6 @@
"""Deterministic nodes and queue controls installed only in an owned test host."""
import os
import threading
import time
@@ -293,3 +294,12 @@ async def image_monitor(request: web.Request) -> web.Response:
monitor.stop_monitoring()
config.MONITORING_ENABLED = False
return web.json_response({"monitoring": monitor.is_monitoring})
@PromptServer.instance.routes.get("/pm_execution_fixture/monitor")
async def image_monitor_state(request: web.Request) -> web.Response:
"""File names of keyless images waiting for their run's recorded outputs."""
monitor = _plugin_module("utils.image_monitor")._monitor_instance
handler = getattr(monitor, "handler", None)
waiting = handler.awaiting_history() if handler is not None else []
return web.json_response({"awaiting": [os.path.basename(p) for p in waiting]})
+32
View File
@@ -108,3 +108,35 @@ test('an output whose positive is only linked prepend text stays unlinked, never
expect(cached?.[1].nodes).toEqual(expect.arrayContaining(['11', '20']));
await linkedTo(execution, rerun, llmText);
});
test('an output saved without metadata links once its recorded run names a stored prompt', async ({ execution }, testInfo) => {
const llmText = `bare output landscape ${testInfo.project.name} ${randomUUID()}`;
const request = execution.request;
expect((await request.post('/pm_execution_fixture/metadata', { data: { disabled: true } })).ok()).toBe(true);
try {
// No embedded graph: no run key can prove the tracker entry, so only the
// run's recorded history can, and only once the run's outputs are sealed.
// The gate after SaveImage holds the run open while the monitor looks.
const prompt = graph({ kind: 'PromptManager', text: 'llm', llm: llmText, prefix: 'misslink/bare' });
prompt['80'] = { class_type: 'PMHistoryImageOutput', inputs: { images: ['70', 0], mode: 'gate' } };
expect((await request.post('/pm_execution_fixture/gate/hold')).ok()).toBe(true);
const id = randomUUID();
try {
expect((await request.post('/prompt', { data: { prompt, prompt_id: id } })).ok()).toBe(true);
let waiting = [];
await expect.poll(async () => {
waiting = (await (await request.get('/pm_execution_fixture/monitor')).json()).awaiting;
return waiting.filter(name => name.startsWith('bare_')).length;
}, { timeout: 20000, message: 'the monitor processed the bare output while its run was open' }).toBe(1);
const [bare] = waiting.filter(name => name.startsWith('bare_'));
expect((await readLinks(execution.databasePath, [bare])).links[bare]).toBeNull();
expect((await request.post('/pm_execution_fixture/gate/release')).ok()).toBe(true);
await linkedTo(execution, bare, llmText);
expect((await (await request.get('/pm_execution_fixture/monitor')).json()).awaiting).not.toContain(bare);
} finally {
expect((await request.post('/pm_execution_fixture/gate/release')).ok()).toBe(true);
}
} finally {
expect((await request.post('/pm_execution_fixture/metadata', { data: { disabled: false } })).ok()).toBe(true);
}
});
+2 -2
View File
@@ -32,13 +32,13 @@ test('execution script discovers only the new matrix and preserves all 78 origin
assert.match(executions, /\[classic-start\]/);
assert.match(executions, /\[nodes2-start\]/);
// + the fresh-host library location case, + the #342 image attribution case
assert.match(executions, /Total: 50 tests in 4 files/);
assert.match(executions, /Total: 52 tests in 4 files/);
for (const project of ['classic-start', 'nodes2-start']) {
const cases = executions.split('\n').filter(line => line.includes(`[${project}]`));
assert.equal(cases.filter(line => line.includes('execution-history.spec.mjs')).length, 15);
assert.equal(cases.filter(line => line.includes('execution-outputs.spec.mjs')).length, 8);
assert.equal(cases.filter(line => line.includes('library-location.spec.mjs')).length, 1);
assert.equal(cases.filter(line => line.includes('image-attribution.spec.mjs')).length, 1);
assert.equal(cases.filter(line => line.includes('image-attribution.spec.mjs')).length, 2);
}
assert.doesNotMatch(executions, /prompt-manager.spec/);
});