Skip to content

bug(agents): unread background completion becomes transcript-only during a long parent tool call #1092

Description

@jparradog

Before submitting

The reporting agent completed these operational checks; these attestations describe the agent's work.

  • I searched open and closed issues and did not find a report of this problem.
  • I reviewed this report and removed credentials, tokens, private paths, hostnames, and other sensitive data.

Problem

An unread first completion can be suppressed from the parent model solely because another parent tool call lasts long enough. The task and report remain retrievable, but the completion becomes a TUI-only custom entry, without a model-facing message or wake request. The parent can then say it is still awaiting verification and become idle until a human intervenes.

This is a narrowly scoped follow-up to #867 / #913, not a request to restore stale replay. #867 concerned already-consumed results. PR #913 explicitly acknowledges the long-turn tradeoff; this report supplies an unread-result occurrence and deterministic component evidence for that branch.

Observed retained evidence, task identities omitted (UTC, 2026-09-16):

Time Observation
03:12:35.013 Parent receives background verifier launch confirmation.
03:13:29.999 Parent starts synchronous subagent_continue for a different task.
03:13:41.314 Verifier session records final assistant message, stopReason: stop.
03:13:41.430 Retained verifier metadata records completed, 13 turns, 20 tool calls, no error.
03:15:22.954 Parent's synchronous tool returns after approximately 113 seconds.
03:15:22.955 Parent appends custom / gentle-agents.stale-result, ageSeconds: 102.
03:17:48.706 Parent says independent verification is pending, without checking status.
03:22:04.927 Human asks why work stopped.
03:22:13–18 Parent queries status and retrieves the completed report.

Across the inspected transcript, this task has one stale entry and zero gentle-agents.result messages. A successful control task in the same session completed at 03:11:21.461 and received its normal completion message at 03:11:21.466, followed by parent activity. No compaction or branch-summary entry appears in the incident interval.

Source trace: agent_end retains the report; agent_settled allows terminal handling; the runner confirms process exit, updates task state and calls onFinish. The extension requests persistence and enqueues completion. A busy parent flushes at a turn/run boundary. takeDeliverable() classifies still-unconsumed completions aged >=90 seconds as stale and marks them delivered. deliverStale() uses appendEntry, not sendMessage. Pi documents custom entries as excluded from LLM context.

Source references at the examined revision:

Limits: raw historical RPC event timestamps and the exact revision loaded into the historical processes were not captured. The timeline observes persisted messages/task state; lifecycle internals are a source trace. The parent also omitted a final status check, but there is no evidence it received and ignored this completion message. This report does not establish a Herdr, provider, or Pi scheduler defect. #1001/#1034 concern startup/watchdog failures, #1028 print-mode exit, and #626 aggregate Herdr status; those are distinct.

The queue file is byte-identical in published v2.7.0 and inspected remote main 002c98101972aefe437fad1669a80a43bb930791 (Git blob 90a8f4d997e458c563a83c450bc2384cdc42788a). That main revision also retains the stale appendEntry branch. No published fix for this unread-first-delivery case was identified.

Steps to reproduce

Executed deterministic component reproduction: run the command below from a checkout of revision 657e532997e13ac196a0ad1e01329fc1163ffd69, using Node 24. SHA-256 guards deliberately stop on source drift. No dependencies, build, Pi startup, model call, real timer or child process under test is needed. Only the production queue and delivery closure are evaluated, with a fake host and clock.

An independent verifier first confirmed this component behavior. During publication preparation, a parent spot-check correctly stopped on a hash mismatch: the installed checkout had advanced to 002c981.... No GitHub mutation occurred. The publishing agent then read the two immutable files from 657e532... into a private temporary fixture and executed the exact check below successfully, including the observed 102-second case. No installed source was edited or checked out, and no build was performed.

node --no-warnings <<'NODE'
const fs = require('node:fs');
const vm = require('node:vm');
const assert = require('node:assert/strict');
const { createHash } = require('node:crypto');
const { stripTypeScriptTypes } = require('node:module');
const queue = fs.readFileSync('lib/agents-completion-delivery.ts', 'utf8');
const extension = fs.readFileSync('extensions/gentle-agents.ts', 'utf8');
const hash = s => createHash('sha256').update(s).digest('hex');
assert.equal(hash(queue), '9d6026dbaf3b84bb34c71ef4fdf27e50dd30978029eae9c0c2ad04d911f9607a');
assert.equal(hash(extension), 'ab68576cc2d6f389ff02eee50e3d34fcf0b3225fb12da25a5f5da32328dfe77c');
function region(s, start, end) {
  assert.equal(s.split(start).length, 2);
  assert.equal(s.split(end).length, 2);
  const a = s.indexOf(start), b = s.indexOf(end);
  assert(b > a && b - a < 12000);
  return s.slice(a, b);
}
const closure = region(extension,
  '\tconst completions = createCompletionQueue<TaskRecord>();',
  '\tconst runner = new AgentRunner');
let now = 0;
const handlers = new Map(), sent = [], entries = [];
const box = vm.createContext({
  activeSessionId: () => 'parent', deps: { now: () => now },
  completionText: t => `done:${t.id}`, taskDetails: t => ({ id: t.id }),
  AGENTS_RESULT_TYPE: 'result', AGENTS_STALE_RESULT_TYPE: 'stale',
  pi: {
    on: (name, fn) => handlers.set(name, fn),
    sendMessage: (message, options) => sent.push({ message, options }),
    appendEntry: (type, data) => entries.push({ type, data }),
  },
}, { codeGeneration: { strings: false, wasm: false } });
vm.runInContext(stripTypeScriptTypes(
  queue.replace(/^export /gm, '') + '\n' + closure +
  '\nglobalThis.probe = { settleCompletion, completions };',
  { mode: 'strip' }), box, { timeout: 1000 });
const fire = name => handlers.get(name)();
const enqueue = id => box.probe.settleCompletion({
  id, parentSessionId: 'parent', agent: 'fixture', label: id, status: 'completed',
});
const steering = index => {
  assert.equal(sent[index].options.deliverAs, 'steer');
  assert.equal(sent[index].options.triggerTurn, true);
};
fire('agent_start'); enqueue('fresh');
assert.equal(sent.length, 0);
now = 89999; fire('turn_end');
assert.equal(sent.length, 1); steering(0);
fire('agent_end');
console.log('PASS unread age=89999ms: steer + triggerTurn');

for (const age of [90000, 102000]) {
  now += 1000; fire('agent_start'); enqueue(`unread-${age}`);
  now += age; fire('turn_end');
  assert.equal(sent.length, 1);
  assert.equal(entries.at(-1).type, 'stale');
  assert.equal(entries.at(-1).data.ageSeconds, age / 1000);
  const count = entries.length;
  fire('agent_end'); fire('agent_settled');
  assert.equal(sent.length, 1); assert.equal(entries.length, count);
  console.log(`PASS unread age=${age}ms: appendEntry only; no later redelivery`);
}
now += 1000; fire('agent_start'); enqueue('consumed');
box.probe.completions.consume('consumed');
fire('turn_end'); fire('agent_end');
assert.equal(sent.length, 1); assert.equal(entries.length, 2);
console.log('PASS consumed completion: suppressed');
now += 1000; enqueue('idle');
assert.equal(sent.length, 2); steering(1);
console.log('PASS idle parent: immediate steer + triggerTurn');
console.log('PASS extracted production queue + delivery closure; no real host/model/process');
NODE

The controls cover unread completions at 89,999, 90,000 and 102,000 ms, no stale re-delivery at later agent_end/agent_settled, explicit consumption, and immediate delivery to an idle parent. The failing delivery condition is asserted as current behavior; PASS means the diagnostic successfully demonstrated it, not that the product is fixed.

Optional full-runtime confirmation recipe, not executed as a controlled reproduction: launch background task A; keep the parent inside a separate foreground tool call for over 90 seconds after A finishes; return without retrieving A's result. Check for a stale custom entry and no corresponding completion message. The model's later decision to poll or remain idle is not deterministic; the missing model-facing completion is the component assertion.

Expected and actual behavior

Expected: the first unread completion remains actionable after a legitimate long parent turn, without replaying consumed results or crossing session ownership. A compact result-available signal could satisfy this; the report does not prescribe the final design.

Actual: age alone converts an unread completion to transcript-only content, marks it delivered, and leaves no later delivery at agent_settled. Waiting inside one tool for 90 seconds does not prove the parent had multiple opportunities to consume the result.

Please preserve consumed-result suppression and session gates. Increasing unrelated watchdog timeouts does not address this branch.

gentle-pi version

2.7.0. Initial installed checkout inspected and reproduction pinned to 657e532997e13ac196a0ad1e01329fc1163ffd69. The installed checkout advanced externally to 002c98101972aefe437fad1669a80a43bb930791 during publication preparation. Historical loaded-process revision was not attested. The reproducible command pins both source hashes explicitly.

Pi version

Installed @earendil-works/pi-coding-agent 0.85.1; interactive parent with RPC children. Node v24.19.0. Configured subagent stall timeout: 600000 ms; continuation enabled. The 90000 ms completion threshold is a separate hard-coded constant.

Operating system

Windows (WSL)

Relevant logs or error output (optional)

PASS unread age=89999ms: steer + triggerTurn
PASS unread age=90000ms: appendEntry only; no later redelivery
PASS unread age=102000ms: appendEntry only; no later redelivery
PASS consumed completion: suppressed
PASS idle parent: immediate steer + triggerTurn
PASS extracted production queue + delivery closure; no real host/model/process
OBSERVED_EXIT=0

Sanitized persisted-entry projection, not raw RPC:
{"type":"custom","customType":"gentle-agents.stale-result","data":{"status":"completed","ageSeconds":102}}
Corresponding gentle-agents.result messages for this task: 0

Fake host + clock: extracted component behavior, not a real scheduler or end-to-end test.

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingstatus:needs-reviewAwaiting maintainer review/approvaltype:bugBug fix

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions