[eric] canvas: stopping a workflow run keeps its card, because you stopped it to read it (ENG-420)

This commit is contained in:
ciregenz
2026-08-27 18:19:00 -07:00
parent 3ccbeb680d
commit 3aa50b6aef
2 changed files with 54 additions and 2 deletions
@@ -42,3 +42,41 @@ test('notifications are still a separate question from cards', () => {
assert.equal(deservesCanvasCard(run('running')), true);
assert.equal(isUserLaunchedSession(run('running')), false);
});
// ENG-420: stopping a workflow-backed agent closed its whole card. Haik, production 1.7.9:
// "Stop should mean stop, not close" -- you lose the transcript the moment you stop the run, which
// is the thing you stopped it to read. The despawn rule could not tell "the nightly run finished"
// from "a person pressed Stop", and it was written for the first one.
test('a run a HUMAN stopped keeps its card', () => {
assert.equal(deservesCanvasCard({
mode: 'agent', workflow_run_id: 'r1', status: 'stopped', ended_by_user: true,
}), true);
});
test('a run that ended on its own still despawns', () => {
// The litter case the rule exists for: a nightly workflow must not leave a card behind every night.
for (const status of ['completed', 'stopped', 'error']) {
assert.equal(deservesCanvasCard({ mode: 'agent', workflow_run_id: 'r1', status }), false, status);
}
});
test('Close still dismisses it, even though Close also sets ended_by_user', () => {
// Both routes stamp ended_by_user; only Close stamps closed_at, which is what separates them.
assert.equal(deservesCanvasCard({
mode: 'agent', workflow_run_id: 'r1', status: 'stopped',
ended_by_user: true, closed_at: '2026-08-28T00:00:00Z',
}), false);
});
test('a live run is unaffected either way', () => {
for (const status of ['running', 'waiting_approval']) {
assert.equal(deservesCanvasCard({ mode: 'agent', workflow_run_id: 'r1', status }), true, status);
assert.equal(deservesCanvasCard({
mode: 'agent', workflow_run_id: 'r1', status, ended_by_user: true,
}), true, `${status} + stopped flag`);
}
});
test('a user-launched chat never depends on any of this', () => {
assert.equal(deservesCanvasCard({ mode: 'agent', status: 'stopped' }), true);
});
@@ -6,6 +6,10 @@ export interface SessionOrigin {
mode: string;
workflow_run_id?: string | null;
workflow_edit_id?: string | null;
// A human pressed Stop or Close. Set by those two routes only; a watchdog's stop never sets it.
ended_by_user?: boolean;
// Set by Close, never by Stop. It is what tells the two apart here.
closed_at?: string | null;
}
/** A run that is still working, so its card is worth having on screen. */
@@ -29,8 +33,18 @@ export function isUserLaunchedSession(session: SessionOrigin): boolean {
* the dock path is gated on the parent card existing, so no card meant no owner. Give the run a card
* and the existing inline-dock plus despawn logic applies to it unchanged. The card goes when the run
* stops, or a nightly workflow would leave one behind every single night.
*
* EXCEPT when a human stopped it. "Ends on its own" and "a person hit Stop" are different events and
* this could not tell them apart, so pressing Stop on a workflow-backed agent made its card vanish
* along with the transcript the user stopped it to read (ENG-420). Someone stops a run to LOOK at
* it; the nightly-litter case it was written for is the one that ends by itself, which still
* despawns. Close is what dismisses a card, and Close is the only route that stamps `closed_at`.
*/
export function deservesCanvasCard(session: SessionOrigin & { status?: string }): boolean {
export function deservesCanvasCard(
session: SessionOrigin & { status?: string },
): boolean {
if (isUserLaunchedSession(session)) return true;
return !!session.workflow_run_id && LIVE_STATUSES.has(session.status ?? '');
if (!session.workflow_run_id) return false;
if (LIVE_STATUSES.has(session.status ?? '')) return true;
return !!session.ended_by_user && !session.closed_at;
}