[GH-ISSUE #6095] [Bug] Summary CLI cancellation drops the committed evidence-bundle receipt #3728

Closed
opened 2026-10-31 00:43:26 +00:00 by bsakaguchi · 1 comment
Owner

Originally created by @coygeek on GitHub (Oct 21, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/6095

Originally assigned to: @kaoru0822-kitauji on GitHub.

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

CLI

Observed behavior

inspect-model-artifact --detail summary --evidence_output DESTINATION --json returns typed cancellation and exit status 130 when SIGINT arrives during staging cleanup after the bundle has been committed. The valid bundle remains at DESTINATION, but the cancellation JSON contains neither its publication receipt nor its destination. Its details.partial_observation contains the summary Evidence only.

The same command without interruption returns {summary, evidence_bundle: {path, bytes}}. A pre-publication interruption returns cancellation without creating the destination. These were executed through the built CLI with a synthetic safetensors input and real filesystem publication, cleanup and signal handling. A wrapper synchronizes the signal with the publication boundary; it does not replace any analysis result or inject a cleanup failure.

Reproduction

Use a built checkout at the revision below, with locked dependencies installed. Put these two files in an existing disposable directory. Run the driver with a supported Node executable, passing the checkout root and that directory:

node receipt-repro.mjs CHECKOUT REPRODUCTION_DIRECTORY

receipt-repro.mjs:

import { writeFileSync, readFileSync, existsSync, statSync, rmSync } from 'node:fs';
import { resolve, join } from 'node:path';
import { spawnSync } from 'node:child_process';
import assert from 'node:assert/strict';
const [checkout, directory] = process.argv.slice(2).map(value=>resolve(value));
const header = Buffer.from(JSON.stringify({weight:{dtype:'F32',shape:[1],data_offsets:[0,4]}}));
const length = Buffer.alloc(8); length.writeBigUInt64LE(BigInt(header.length));
const fixture = join(directory,'fixture.safetensors');
writeFileSync(fixture,Buffer.concat([length,header,Buffer.alloc(4)]));
for (const mode of ['normal','before','after']) {
  const destination = join(directory,`${mode}.bundle.json`);
  rmSync(destination,{force:true});
  const run = spawnSync(process.execPath,[
    ...(mode === 'normal' ? [] : ['--import',join(directory,'signal-hook.mjs')]),
    join(checkout,'scripts/rea.mjs'),'inspect-model-artifact',fixture,
    '--detail','summary','--evidence_output',destination,'--json'
  ],{encoding:'utf8',timeout:30000,env:{...process.env,REPRO_CHECKOUT:checkout,REPRO_DESTINATION:destination,REPRO_MODE:mode}});
  if(run.error) throw run.error;
  writeFileSync(join(directory,`${mode}.stdout.json`),run.stdout);
  writeFileSync(join(directory,`${mode}.stderr.txt`),run.stderr);
  const value=JSON.parse(run.stdout);
  const present=existsSync(destination);
  console.log(JSON.stringify({mode,status:run.status,signal:run.signal,exists:present,bytes:present?statSync(destination).size:0,code:value.code,keys:Object.keys(value),partial_keys:Object.keys(value.details?.partial_observation??{}),receipt:value.evidence_bundle??value.details?.partial_observation?.evidence_bundle??null,stderr:run.stderr}));
  if(mode==='normal') { assert.equal(run.status,0); assert.equal(value.evidence_bundle.bytes,statSync(destination).size); }
  else if(mode==='before') { assert.equal(run.status,130); assert.equal(value.code,'cancelled'); assert.equal(present,false); }
  else { assert.equal(run.status,130); assert.equal(value.code,'cancelled'); assert.equal(present,true); assert.equal(JSON.stringify(value).includes(destination),false); const bundle=JSON.parse(readFileSync(destination,'utf8')); assert.equal(bundle.records.length,1); }
}

signal-hook.mjs:

import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import assert from 'node:assert/strict';
const runtime=resolve(process.env.REPRO_CHECKOUT,'dist/cli-runtime');
const entry=readdirSync(runtime).find(name=>name.startsWith('OwnedStagedOutput-')&&name.endsWith('.js'));
const module=await import(pathToFileURL(resolve(runtime,entry)));
module.init_OwnedStagedOutput();
const { OwnedStagedOutput }=module;
let interrupted=false;
async function interrupt() {
  assert.ok(process.listenerCount('SIGINT')>0,'CLI cancellation handler is installed');
  let timeout;
  const observed=new Promise((resolve,reject)=>{ process.once('SIGINT',resolve); timeout=setTimeout(()=>reject(new Error('SIGINT was not observed')),2000); });
  process.kill(process.pid,'SIGINT');
  try { await observed; } finally { clearTimeout(timeout); }
}
if(process.env.REPRO_MODE==='after') {
  const close=OwnedStagedOutput.prototype.close;
  OwnedStagedOutput.prototype.close=async function() {
    if(!interrupted && existsSync(process.env.REPRO_DESTINATION)) {
      interrupted=true;
      const bundle=JSON.parse(readFileSync(process.env.REPRO_DESTINATION,'utf8'));
      assert.equal(bundle.records.length,1);
      console.error('confirmed committed bundle before SIGINT');
      await interrupt();
    }
    return close.call(this);
  };
} else {
  const ready=OwnedStagedOutput.prototype.assertReadyForPublication;
  OwnedStagedOutput.prototype.assertReadyForPublication=async function(...args) {
    await ready.apply(this,args);
    if(!interrupted) { interrupted=true; assert.equal(existsSync(process.env.REPRO_DESTINATION),false); console.error('SIGINT before publication'); await interrupt(); }
  };
}

The hook locates the class actually used by the packaged CLI runtime. In the after case it verifies and parses the committed destination, sends a real SIGINT, waits until the signal is handled, then calls the original cleanup method. In the before case it interrupts after readiness inspection and before the next precommit signal check. The timer only keeps the signal wait alive and detects a missing signal; it does not select the lifecycle phase.

Expected behavior

Preserve the committed {path, bytes} receipt in the typed cancellation result, alongside the retained Evidence. The exact field placement can follow the existing partial-observation contract. Do not report a receipt before publication, erase a committed bundle, or imply that a cancelled request produced no output.

The lifecycle contract explicitly requires retaining a committed output's publication receipt. The atomic-write regression guidance requires checking both sides of the commit point. The writer itself deliberately permits publication already submitted to win a later cancellation.

Impact and scope

P2: callers consuming the structured cancellation output lose the receipt that distinguishes a committed durable bundle from an interrupted write. A repeated command at the same destination returns invalid_request with details.reason: "exists". Automated recovery therefore needs to retain the original destination independently and inspect its contents.

A practical recovery was executed: evidence-import DESTINATION --json successfully imported the retained bundle and reported one record. No output corruption or loss occurred. The demonstrated scope is summary delivery for inspect-model-artifact; other consumers of the shared publication helper are source-derived. Natural timing frequency, SIGTERM and other operating systems were not measured.

REA version

rea-agents 6.7.0; source revision 52415975942a4b9c53a817915b313ccc404f280e.

Runtime and platform

macOS 27.0.1 arm64; Node.js v24.22.0. This runtime satisfies the package engines range; the repository's Node 24.18.0 pin was not exercised. Existing build artifacts were used without rebuilding or changing dependencies.

Target and artifact context

Synthetic safetensors header declaring one F32 element plus four data bytes. The output is a canonical Evidence bundle containing one complete inspection record. No external provider, model loader, weights execution, target process or network service was required.

Interface

CLI

Earliest failure stage

Transport or result projection

Relevant output or evidence

The final three-case driver run passed all observed-behavior assertions. Selected diagnostic fields were:

{"case":"normal","exit":0,"destination_exists":true,"bundle_bytes":2868,"receipt_present":true}
{"case":"before","exit":130,"code":"cancelled","destination_exists":false,"receipt_present":false}
{"case":"after","exit":130,"code":"cancelled","destination_exists":true,"bundle_bytes":2868,"receipt_present":false,"destination_in_result":false}

These are diagnostic projections, not literal REA payload fields. The byte count includes fixture-path metadata and may differ in another directory. The interrupted result contains details.partial_observation.evidence_id and the summary's normalized_result, but no evidence_bundle field. The hook read the committed record before sending SIGINT; the driver independently parsed the still-present bundle afterwards. No staging directory remained after the completed run.

A separate built CLI readback returned:

{"imported":1,"unknowns_added":0,"total":1}

A subsequent inspection using the same evidence destination exited 1 with code: "invalid_request" and details.reason: "exists".

Typed outcome or recovery expected

Keep the cancellation classification, conventional interrupt exit status and collected summary Evidence. Include the already-known complete-bundle publication receipt so the caller can import or otherwise use that committed output without guessing whether the write completed. A precommit cancellation should continue to leave the destination absent.

Analysis or suspected cause

Source-derived: the supported command uses the shared delivery helper. Publication and composition store the writer receipt in a closure variable, while the operation passed to finishAnalysisCommand returns only Evidence. The finisher converts successful Evidence into cancellation after cleanup. The final isEvidence(delivered) guard then skips the receipt wrapper for this projected error.

The writer commits before awaiting staging cleanup, and returns success after that cleanup even when the signal has since aborted. The receipt needs to travel with that completed operation through the cancellation decision; the generic finisher cannot recover a receipt it never received.

Additional context

Fetched upstream main d8a9673fc44fde4bd30ff98c241886e6683580ca has unchanged CLI delivery/finisher/model-command logic and atomic success path. Newer JsonFiles changes bind cleanup-failure leases and do not change this successful-cleanup interleaving. This is a source freshness comparison; that newer runtime was not built or executed.

Related but separate late-cancellation reports: #6048, #6049, #6072, #6078 and #6080. Those concern completion checks or ADB cleanup-primary selection at other owners. This report keeps the existing cancellation result and asks only that the committed evidence-bundle receipt be preserved alongside the retained summary Evidence.

Related #6051 and #6079 concern receipts lost by Kuna retirement and ADB digest-failure producers. #3935 / PR #4238 concern completed-result preservation through provider close failure, while #4261 concerns service result consumption before publication. #4276 is a raw-partial MCP recovery design question. PR #5167 adds managed-member summary delivery and receipt handling through a different application delivery path; it does not change this shared CLI publication helper. None carries this helper's already successful evidence-bundle receipt into its late-cancellation result. No regression range or repair has been established.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6095 Originally assigned to: @kaoru0822-kitauji on GitHub. ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area CLI ### Observed behavior `inspect-model-artifact --detail summary --evidence_output DESTINATION --json` returns typed cancellation and exit status 130 when SIGINT arrives during staging cleanup after the bundle has been committed. The valid bundle remains at DESTINATION, but the cancellation JSON contains neither its publication receipt nor its destination. Its `details.partial_observation` contains the summary Evidence only. The same command without interruption returns `{summary, evidence_bundle: {path, bytes}}`. A pre-publication interruption returns cancellation without creating the destination. These were executed through the built CLI with a synthetic safetensors input and real filesystem publication, cleanup and signal handling. A wrapper synchronizes the signal with the publication boundary; it does not replace any analysis result or inject a cleanup failure. ### Reproduction Use a built checkout at the revision below, with locked dependencies installed. Put these two files in an existing disposable directory. Run the driver with a supported Node executable, passing the checkout root and that directory: ```sh node receipt-repro.mjs CHECKOUT REPRODUCTION_DIRECTORY ``` `receipt-repro.mjs`: ```js import { writeFileSync, readFileSync, existsSync, statSync, rmSync } from 'node:fs'; import { resolve, join } from 'node:path'; import { spawnSync } from 'node:child_process'; import assert from 'node:assert/strict'; const [checkout, directory] = process.argv.slice(2).map(value=>resolve(value)); const header = Buffer.from(JSON.stringify({weight:{dtype:'F32',shape:[1],data_offsets:[0,4]}})); const length = Buffer.alloc(8); length.writeBigUInt64LE(BigInt(header.length)); const fixture = join(directory,'fixture.safetensors'); writeFileSync(fixture,Buffer.concat([length,header,Buffer.alloc(4)])); for (const mode of ['normal','before','after']) { const destination = join(directory,`${mode}.bundle.json`); rmSync(destination,{force:true}); const run = spawnSync(process.execPath,[ ...(mode === 'normal' ? [] : ['--import',join(directory,'signal-hook.mjs')]), join(checkout,'scripts/rea.mjs'),'inspect-model-artifact',fixture, '--detail','summary','--evidence_output',destination,'--json' ],{encoding:'utf8',timeout:30000,env:{...process.env,REPRO_CHECKOUT:checkout,REPRO_DESTINATION:destination,REPRO_MODE:mode}}); if(run.error) throw run.error; writeFileSync(join(directory,`${mode}.stdout.json`),run.stdout); writeFileSync(join(directory,`${mode}.stderr.txt`),run.stderr); const value=JSON.parse(run.stdout); const present=existsSync(destination); console.log(JSON.stringify({mode,status:run.status,signal:run.signal,exists:present,bytes:present?statSync(destination).size:0,code:value.code,keys:Object.keys(value),partial_keys:Object.keys(value.details?.partial_observation??{}),receipt:value.evidence_bundle??value.details?.partial_observation?.evidence_bundle??null,stderr:run.stderr})); if(mode==='normal') { assert.equal(run.status,0); assert.equal(value.evidence_bundle.bytes,statSync(destination).size); } else if(mode==='before') { assert.equal(run.status,130); assert.equal(value.code,'cancelled'); assert.equal(present,false); } else { assert.equal(run.status,130); assert.equal(value.code,'cancelled'); assert.equal(present,true); assert.equal(JSON.stringify(value).includes(destination),false); const bundle=JSON.parse(readFileSync(destination,'utf8')); assert.equal(bundle.records.length,1); } } ``` `signal-hook.mjs`: ```js import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import assert from 'node:assert/strict'; const runtime=resolve(process.env.REPRO_CHECKOUT,'dist/cli-runtime'); const entry=readdirSync(runtime).find(name=>name.startsWith('OwnedStagedOutput-')&&name.endsWith('.js')); const module=await import(pathToFileURL(resolve(runtime,entry))); module.init_OwnedStagedOutput(); const { OwnedStagedOutput }=module; let interrupted=false; async function interrupt() { assert.ok(process.listenerCount('SIGINT')>0,'CLI cancellation handler is installed'); let timeout; const observed=new Promise((resolve,reject)=>{ process.once('SIGINT',resolve); timeout=setTimeout(()=>reject(new Error('SIGINT was not observed')),2000); }); process.kill(process.pid,'SIGINT'); try { await observed; } finally { clearTimeout(timeout); } } if(process.env.REPRO_MODE==='after') { const close=OwnedStagedOutput.prototype.close; OwnedStagedOutput.prototype.close=async function() { if(!interrupted && existsSync(process.env.REPRO_DESTINATION)) { interrupted=true; const bundle=JSON.parse(readFileSync(process.env.REPRO_DESTINATION,'utf8')); assert.equal(bundle.records.length,1); console.error('confirmed committed bundle before SIGINT'); await interrupt(); } return close.call(this); }; } else { const ready=OwnedStagedOutput.prototype.assertReadyForPublication; OwnedStagedOutput.prototype.assertReadyForPublication=async function(...args) { await ready.apply(this,args); if(!interrupted) { interrupted=true; assert.equal(existsSync(process.env.REPRO_DESTINATION),false); console.error('SIGINT before publication'); await interrupt(); } }; } ``` The hook locates the class actually used by the packaged CLI runtime. In the `after` case it verifies and parses the committed destination, sends a real SIGINT, waits until the signal is handled, then calls the original cleanup method. In the `before` case it interrupts after readiness inspection and before the next precommit signal check. The timer only keeps the signal wait alive and detects a missing signal; it does not select the lifecycle phase. ### Expected behavior Preserve the committed `{path, bytes}` receipt in the typed cancellation result, alongside the retained Evidence. The exact field placement can follow the existing partial-observation contract. Do not report a receipt before publication, erase a committed bundle, or imply that a cancelled request produced no output. The [lifecycle contract](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/tool-design.md#L101-L113) explicitly requires retaining a committed output's publication receipt. The [atomic-write regression guidance](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/testing.md#L317-L319) requires checking both sides of the commit point. The writer itself deliberately permits publication already submitted to win a later cancellation. ### Impact and scope P2: callers consuming the structured cancellation output lose the receipt that distinguishes a committed durable bundle from an interrupted write. A repeated command at the same destination returns `invalid_request` with `details.reason: "exists"`. Automated recovery therefore needs to retain the original destination independently and inspect its contents. A practical recovery was executed: `evidence-import DESTINATION --json` successfully imported the retained bundle and reported one record. No output corruption or loss occurred. The demonstrated scope is summary delivery for `inspect-model-artifact`; other consumers of the shared publication helper are source-derived. Natural timing frequency, SIGTERM and other operating systems were not measured. ### REA version rea-agents 6.7.0; source revision `52415975942a4b9c53a817915b313ccc404f280e`. ### Runtime and platform macOS 27.0.1 arm64; Node.js v24.22.0. This runtime satisfies the package engines range; the repository's Node 24.18.0 pin was not exercised. Existing build artifacts were used without rebuilding or changing dependencies. ### Target and artifact context Synthetic safetensors header declaring one F32 element plus four data bytes. The output is a canonical Evidence bundle containing one complete inspection record. No external provider, model loader, weights execution, target process or network service was required. ### Interface CLI ### Earliest failure stage Transport or result projection ### Relevant output or evidence The final three-case driver run passed all observed-behavior assertions. Selected diagnostic fields were: ```json {"case":"normal","exit":0,"destination_exists":true,"bundle_bytes":2868,"receipt_present":true} {"case":"before","exit":130,"code":"cancelled","destination_exists":false,"receipt_present":false} {"case":"after","exit":130,"code":"cancelled","destination_exists":true,"bundle_bytes":2868,"receipt_present":false,"destination_in_result":false} ``` These are diagnostic projections, not literal REA payload fields. The byte count includes fixture-path metadata and may differ in another directory. The interrupted result contains `details.partial_observation.evidence_id` and the summary's `normalized_result`, but no `evidence_bundle` field. The hook read the committed record before sending SIGINT; the driver independently parsed the still-present bundle afterwards. No staging directory remained after the completed run. A separate built CLI readback returned: ```json {"imported":1,"unknowns_added":0,"total":1} ``` A subsequent inspection using the same evidence destination exited 1 with `code: "invalid_request"` and `details.reason: "exists"`. ### Typed outcome or recovery expected Keep the cancellation classification, conventional interrupt exit status and collected summary Evidence. Include the already-known complete-bundle publication receipt so the caller can import or otherwise use that committed output without guessing whether the write completed. A precommit cancellation should continue to leave the destination absent. ### Analysis or suspected cause Source-derived: [the supported command](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/cli/modelCommands.ts#L76-L101) uses the shared delivery helper. [Publication and composition](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/cli/analysisEvidenceDelivery.ts#L119-L156) store the writer receipt in a closure variable, while the operation passed to `finishAnalysisCommand` returns only Evidence. [The finisher](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/cli/finishAnalysisCommand.ts#L72-L80) converts successful Evidence into cancellation after cleanup. The final `isEvidence(delivered)` guard then skips the receipt wrapper for this projected error. [The writer](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/JsonFiles.ts#L354-L379) commits before awaiting staging cleanup, and returns success after that cleanup even when the signal has since aborted. The receipt needs to travel with that completed operation through the cancellation decision; the generic finisher cannot recover a receipt it never received. ### Additional context Fetched upstream main `d8a9673fc44fde4bd30ff98c241886e6683580ca` has unchanged CLI delivery/finisher/model-command logic and atomic success path. Newer `JsonFiles` changes bind cleanup-failure leases and do not change this successful-cleanup interleaving. This is a source freshness comparison; that newer runtime was not built or executed. Related but separate late-cancellation reports: [#6048](https://github.com/morluto/rea/issues/6048), [#6049](https://github.com/morluto/rea/issues/6049), [#6072](https://github.com/morluto/rea/issues/6072), [#6078](https://github.com/morluto/rea/issues/6078) and [#6080](https://github.com/morluto/rea/issues/6080). Those concern completion checks or ADB cleanup-primary selection at other owners. This report keeps the existing cancellation result and asks only that the committed evidence-bundle receipt be preserved alongside the retained summary Evidence. Related #6051 and #6079 concern receipts lost by Kuna retirement and ADB digest-failure producers. #3935 / PR #4238 concern completed-result preservation through provider close failure, while #4261 concerns service result consumption before publication. #4276 is a raw-partial MCP recovery design question. [PR #5167](https://github.com/morluto/rea/pull/5167) adds managed-member summary delivery and receipt handling through a different application delivery path; it does not change this shared CLI publication helper. None carries this helper's already successful evidence-bundle receipt into its late-cancellation result. No regression range or repair has been established.
Author
Owner

@kaoru0822-kitauji commented on GitHub (Oct 22, 2026):

Reproduced on origin/main ce5bc8cb0 (macOS arm64, Node v26.5.0) with the issue's receipt-repro.mjs / signal-hook.mjs against a freshly built worktree:

  • normal: exit 0, {summary, evidence_bundle: {path, bytes: 2910}}
  • before: exit 130, code: cancelled, destination absent
  • after: exit 130, code: cancelled, destination present (2910 bytes, one record), no receipt or destination anywhere in the JSON; details.partial_observation is the summary Evidence only.

Source: publishCliAnalysisEvidence (src/cli/analysisEvidenceDelivery.ts) captures the receipt in a local and only returns it on success; finishAnalysisCommand creates the post-close cancellation with the summary as its sole partial observation. That helper backs every --evidence_output CLI family (model, go, rust, gpu, il2cpp, desktop, web network capture, artifact, objdump/gdb, native function matching). inspect-managed-members --evidence-bundle already keeps the receipt as details.diagnostics.evidence_bundle through deliverAnalysisEvidence. Working on a fix that routes both through one publication owner.

<!-- gh-comment-id:6331144395 --> @kaoru0822-kitauji commented on GitHub (Oct 22, 2026): Reproduced on `origin/main` ce5bc8cb0 (macOS arm64, Node v26.5.0) with the issue's `receipt-repro.mjs` / `signal-hook.mjs` against a freshly built worktree: - `normal`: exit 0, `{summary, evidence_bundle: {path, bytes: 2910}}` - `before`: exit 130, `code: cancelled`, destination absent - `after`: exit 130, `code: cancelled`, destination present (2910 bytes, one record), no receipt or destination anywhere in the JSON; `details.partial_observation` is the summary Evidence only. Source: `publishCliAnalysisEvidence` (src/cli/analysisEvidenceDelivery.ts) captures the receipt in a local and only returns it on success; `finishAnalysisCommand` creates the post-close cancellation with the summary as its sole partial observation. That helper backs every `--evidence_output` CLI family (model, go, rust, gpu, il2cpp, desktop, web network capture, artifact, objdump/gdb, native function matching). `inspect-managed-members --evidence-bundle` already keeps the receipt as `details.diagnostics.evidence_bundle` through `deliverAnalysisEvidence`. Working on a fix that routes both through one publication owner.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#3728
No description provided.