You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
A job collected with shell_status is still delivered, so its result arrives twice #2
Collecting a finished background job with shell_status {id} is meant to cancel its automatic delivery (the comment at shell_status: "Reading a finished job marks it collected so it will not also be delivered unasked"). In practice it never does: the delivery is already queued by the time shell_status marks the job collected, so the model gets the result from shell_status and then again as a follow-up message.
Seen in 0.4.0 (f515ac7), pi on Windows, interactive session.
Trace
The model calls shell_status {id: "bg-3", wait: 15}. The job is running, so shell_status polls job.status every 250 ms.
The process exits. settle sets job.status = "done". The exit.then(...) in scheduleDelivery runs on the next microtask, sees job.delivered === false, sets it to true, and calls pi.sendMessage(..., { deliverAs: "followUp", triggerTurn: true }).
Up to 250 ms later the poll wakes, sees done, sets delivered = true (already true), and returns the result.
The turn ends and pi sends the queued follow-up: the same result a second time, plus a new turn to answer it.
shell_status {id} without wait on a job that already finished duplicates too, since the message was queued when the job ended. Only listing (shell_status with no id) and leaving the job alone deliver once.
Check delivered when the message is sent, not when the process exits. For example, push finished jobs onto a pending list in scheduleDelivery, and drain it when the agent is idle (on agent_end, or right away if no turn is running), skipping any job whose delivered was set in the meantime. A job collected during the turn is then never sent.
PR #1 (deliverResults: false) avoids the duplicate by turning delivery off, but that also drops the completion message for jobs nobody collects.
A local patch against 0.4.0 that fixes this. A finished job is held until the agent loop ends (agent_end) and only then delivered, skipping any job shell_status collected in the meantime. When no turn is running it is delivered at once, as before. Jobs adopted after /reload go through the same path.
@@ -77,6 +77,12 @@
// deliver through the now-stale pi handle. Each instance has its own copy
// (reload builds a fresh closure), so this only ever flips once, on the way out.
let handedOff = false;
+ // Finished jobs waiting for the agent loop to end before they are delivered.+ // Holding them lets a shell_status call later in the same turn collect a job+ // and cancel its delivery: deciding at exit time always beats the poll, so+ // the result arrived twice (pifydev/shell-background#2).+ let busy = false;+ const pending: Job[] = [];
const ROOT = join(tmpdir(), "pify-shell-bg");
const MAX_SESSION_DIR_AGE_MS = 7 * 24 * 60 * 60 * 1000;
@@ -229,6 +235,17 @@
);
}
+ /** Deliver every held job that nothing collected meanwhile, unless a turn is still running. */+ function flushPending(): void {+ if (busy || handedOff) return;+ for (const job of pending.splice(0)) {+ if (job.delivered) continue;+ job.delivered = true;+ registry?.persist(job);+ deliver(job);+ }+ }+
/** Deliver a finished background job into the conversation, once. */
function scheduleDelivery(job: Job, exit: Promise<unknown>): void {
exit
@@ -242,9 +259,8 @@
return;
}
if (job.delivered) return;
- job.delivered = true;- registry?.persist(job);- deliver(job);+ pending.push(job);+ flushPending();
})
.catch(() => {
// A /reload can make captured handles throw; delivery is a convenience,
@@ -614,10 +630,9 @@
}
}
if (job.status === "running") anyRunning = true;
- else if (!job.delivered) {- job.delivered = true;- registry.persist(job);- deliver(job);+ else if (!job.delivered && !pending.includes(job)) {+ pending.push(job);+ flushPending();
}
}
renderWidget(ctx);
@@ -626,6 +641,19 @@
timer.unref?.();
}
+ pi.on("agent_start", async () => {+ busy = true;+ });++ pi.on("agent_end", async () => {+ busy = false;+ try {+ flushPending();+ } catch {+ // Delivery is a convenience; shell_status still collects the result.+ }+ });+
pi.on("session_start", async (_event, ctx) => {
const warnings = loadSettings(ctx.cwd);
const key = sessionKey(ctx);
Tested by hand in an interactive session (pi 1.0.2, Windows, Git Bash):
Case
Before
After
background: true, then shell_status {id, wait}
result twice
once, from shell_status
background: true, finishes during the turn, not collected
once
once, after the turn
auto-backgrounded at 30s, not collected
once
once
Not done: the package's own test suite was not run, and there is no new test. One unverified edge: pi starts a new run for messages queued from an agent_end handler (agent-session.js, "Messages queued by agent_end handlers require a fresh run"). If that run does not emit agent_start again, a job finishing during it is delivered at once, which is the old behaviour.
Activity
chigkim commentedon Oct 5, 2026
A local patch against 0.4.0 that fixes this. A finished job is held until the agent loop ends (
agent_end) and only then delivered, skipping any jobshell_statuscollected in the meantime. When no turn is running it is delivered at once, as before. Jobs adopted after/reloadgo through the same path.Tested by hand in an interactive session (pi 1.0.2, Windows, Git Bash):
background: true, thenshell_status {id, wait}shell_statusbackground: true, finishes during the turn, not collectedNot done: the package's own test suite was not run, and there is no new test. One unverified edge: pi starts a new run for messages queued from an
agent_endhandler (agent-session.js, "Messages queued by agent_end handlers require a fresh run"). If that run does not emitagent_startagain, a job finishing during it is delivered at once, which is the old behaviour.Filed by claude-opus-5-5.
fix(shell-background): a job collected with shell_status is no longer…