Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ product overview.

## Unreleased

- Keep cancelled Slack turns from restoring the loading indicator while their engine shuts down.
Stop and cold-engine steering close progress immediately and ignore late output; cancellation
during preflight cannot start a new indicator.

- Fix open SSH sessions retaining stale channel secrets after additions, rotations or removals.
Refreshes now recheck the developer’s access and close sessions when access is revoked or
their mounted workspace/runtime changes.
Expand Down
10 changes: 8 additions & 2 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -1620,8 +1620,14 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks.
in DMs and in channel threads alike. Pressing it stops exactly that thread through the same
`stopRunsInChannel` path and authorization gate as a `stop` message or 🛑 reaction (redelivered
envelopes are deduplicated, the bot's own events ignored); whether or not a run was found the
session is moved off `processing`, because Slack never does that on its own. Per-thread titles
use `agents.sessions.rename` (200 chars). A workspace or app without the native surface
session is moved off `processing`, because Slack never does that on its own.
Stop also closes that turn's progress producer immediately when its abort signal fires. Late
engine output, warmup notices and status heartbeats cannot restart processing while the runner
unwinds; cold-engine steering uses the same cancellation boundary. A stop during directory or
thread-context preflight does not start a replacement indicator or runner.
Repeated cancellation cleanup calls share one bounded completion, keeping a steered successor
behind its predecessor's pending status clear for the existing cleanup grace.
Per-thread titles use `agents.sessions.rename` (200 chars). A workspace or app without the native surface
(`feature_disabled`, `missing_scope`, `unknown_method`,
`missing_agent_session_stopped_event_subscription`, …) falls back to the legacy methods for the
rest of the process after one warning; a per-thread refusal (`not_authorized`) only skips that
Expand Down
22 changes: 22 additions & 0 deletions TEST-PLAN.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
# ChannelGate — Test Plan

## Slack cancellation progress ordering (2026-10-16)

- [x] Regression: `test/slack-stop-progress.test.js` holds actual pipeline runtime preparation
open after Stop/cold-engine Steer for Claude and Codex. Late warmup notices cannot reassert
`processing`; native status clears before unwind. Real stub children emit late answer deltas
after SIGTERM: neither processing nor the late answer reaches Slack. Directory-preflight Stop
creates no later indicator or runner. Existing queue, thread-context preflight and Stop-card
suites retain their durable cancellation, successor ownership and single-notice checks.
A held native status write plus a fast-exiting cold engine keeps the actual steered successor
queued until the predecessor's shared bounded cleanup completes; its late clear cannot erase
the successor's processing status within that existing grace window.
- [ ] Live **Claude and Codex**, separately, in owned DM and channel fixtures: select each engine
through the native picker, ask “Run sleep 120, then answer STOP_NOT_HONORED”, and press the real
native Stop before completion. Record exact root, loaded revision, resolved engine/model,
click time, native control/loading disappearance interval and process-tree termination; wait
beyond the original sleep deadline to exclude a late answer. Rename the thread and retain its
native rename event. Capture only safe session/thread/status fields from browser WebSocket
events to distinguish status receipt from DOM repaint. **Pass:** one Stopped notice, no surviving
owned process or late answer, no post-clear processing reassertion, and loading clears within
the existing one-second gate. An interval crossing one second is inconclusive; passing source
tests does not establish Slack client timing. Restore test pins and fixture settings.

## Slack Settings picker rendering (2026-10-15)

- [x] Regression: the existing all-page/all-role/inline-form Block Kit validation includes a
Expand Down
27 changes: 23 additions & 4 deletions src/slack/message-pipeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ async function slackPermalink(client, channel, ts) {
return `https://slack.com/archives/${channel}/p${String(ts).replace(".", "")}`;
}

// Terminalize every matching in-flight run synchronously — no Slack calls, no awaits. The shared
// Terminalize every matching in-flight run synchronously — no awaits, including progress cleanup
// started by abort listeners. The shared
// core of the `stop` command (stopRunsInChannel below, which adds the user-facing messaging) and
// `/clear` (which must kill the thread's live run through this exact path BEFORE dropping its
// session: the abort marks handle.aborted, which suppresses the late answer post, the
Expand Down Expand Up @@ -1542,6 +1543,7 @@ export async function processMessageEvent(event, client, { botUserId = "", teamI
// From here EVERYTHING runs inside the try so the finally always releases this turn's queue
// slot — a throw anywhere (even starting the progress indicator) must not jam the thread.
let status = null;
let stopProgressOnAbort = null;
let terminal = false;
const markTerminal = () => {
terminal = true;
Expand All @@ -1551,13 +1553,25 @@ export async function processMessageEvent(event, client, { botUserId = "", teamI
// spent the moment the run resolves, on every branch below, and exactly once.
const bankUsage = createUsageBank();
try {
// Directory lookup above may have yielded after queue ownership. A stopped/steered owner
// must not create a new processing session while its successor is waiting for this slot.
if (handle.controller.signal.aborted || isForceStopping()) {
if (handle.controller.signal.aborted) markTerminal();
return;
}
// Progress indicator per the configured mode (shimmer / activity log / both / native stream).
status = startProgress(getProgressView(), client, event.channel, threadKey, {
isDM: meta.isDM,
authorId: event.user,
teamId,
dir,
});
// Close the producer at cancellation, not after the runner finally settles. Its stop()
// seals callbacks/timers synchronously before awaiting Slack cleanup, so late engine output
// or runtime notices cannot reassert processing after the Stop handler clears the session.
stopProgressOnAbort = () => { void status.stop().catch(() => {}); };
handle.controller.signal.addEventListener("abort", stopProgressOnAbort, { once: true });
if (handle.controller.signal.aborted) stopProgressOnAbort();
// First time the bot is EVER pulled into an existing thread → replay its earlier messages
// for context. Gated on the persistent has-ever-had-a-session marker (not "no current
// session"), so /clear doesn't re-inject the whole history on the next reply; checked
Expand Down Expand Up @@ -1587,8 +1601,8 @@ export async function processMessageEvent(event, client, { botUserId = "", teamI
// preflight. Recheck at the last asynchronous boundary before enriching the durable row;
// otherwise an explicit stop can clear the row and this owner can resurrect it immediately
// afterwards. No await exists between this gate and recordActiveRun below.
if (handle.aborted || isForceStopping()) {
if (handle.aborted) markTerminal();
if (handle.controller.signal.aborted || isForceStopping()) {
if (handle.controller.signal.aborted) markTerminal();
await status.stop();
return;
}
Expand Down Expand Up @@ -1643,12 +1657,16 @@ export async function processMessageEvent(event, client, { botUserId = "", teamI
attachments: attachmentPaths,
signal: handle.controller.signal,
progressReport: true,
onDelta: status.onDelta,
onDelta: (delta) => {
if (!handle.controller.signal.aborted) return status.onDelta(delta);
},
onEvent: (ev) => {
if (handle.controller.signal.aborted) return;
if (ev?.kind === "tool_use" && isMemorySaveTool(ev.name)) memorySavesInTurn += 1;
return status.onEvent(ev);
},
onRuntimeResolved: (runtime) => {
if (handle.controller.signal.aborted) return;
updateActiveRunRuntime(runId, runtime);
status.onRuntimeResolved?.(runtime);
},
Expand Down Expand Up @@ -1853,6 +1871,7 @@ export async function processMessageEvent(event, client, { botUserId = "", teamI
}
}
} finally {
if (stopProgressOnAbort) handle.controller.signal.removeEventListener("abort", stopProgressOnAbort);
// Per-turn terminal state wins over the global shutdown phase: a delivered answer, reported
// error, steering completion, or explicit stop must never replay merely because force-stop
// flipped before this finally ran. Only genuinely interrupted work remains recoverable.
Expand Down
14 changes: 10 additions & 4 deletions src/slack/progress.js
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,7 @@ function startStreamingProgress(client, { channel, threadTs, isDM, authorId, tea
let failed = false;
let stopped = false;
let terminal = null; // "finalize" or "stop" once the run starts closing
let stopCompletion = null; // all cancellation callers share the original cleanup and grace
// Streaming failures used to be swallowed whole, which made field problems (e.g. Slack's
// undocumented limit on how long a message may stay in the streaming state) undiagnosable from
// the logs. One warn per failure site per turn — never the payload, only the error code.
Expand Down Expand Up @@ -1365,16 +1366,16 @@ function startStreamingProgress(client, { channel, threadTs, isDM, authorId, tea
},
// Abort/error path: close the stream so the partial message finalizes cleanly (or clear the
// shimmer if it never started). The caller posts its own error/stop notice.
stop: async () => {
stop: () => {
if (stopCompletion) return stopCompletion;
const bounded = async (work) => {
let timer;
try {
await Promise.race([work, new Promise((resolve) => { timer = setTimeout(resolve, stopGraceMs); })]);
} finally { clearTimeout(timer); }
};
if (terminal) {
await bounded(chain);
return;
return bounded(chain);
}
terminal = "stop";
stopped = true;
Expand All @@ -1398,7 +1399,12 @@ function startStreamingProgress(client, { channel, threadTs, isDM, authorId, tea
}
}
})().catch((error) => reportStreamFailure("abort cleanup", error));
await bounded(cleanup);
// An abort listener may start this before the runner's catch/finally calls stop again.
// Every caller must await the same clear/stream cleanup, not only the stream chain: an
// early queue release could let this turn's pending clear erase its successor's status.
// Sharing the bounded promise also keeps retries from starting a fresh grace period.
stopCompletion = bounded(cleanup);
return stopCompletion;
},
};
}
Expand Down
Loading
Loading