polygram 0.12.0-rc.38 → 0.12.0-rc.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/handlers/abort.js +77 -43
- package/lib/process/cli-process.js +40 -4
- package/package.json +1 -1
package/lib/handlers/abort.js
CHANGED
|
@@ -8,14 +8,16 @@
|
|
|
8
8
|
* 1. Mark the session aborted BEFORE the SDK interrupt fires —
|
|
9
9
|
* pm-sdk's close handler races; if we marked after, the
|
|
10
10
|
* generic error-reply could slip through.
|
|
11
|
-
* 2.
|
|
12
|
-
*
|
|
11
|
+
* 2. Tiered cancel (cheap by default, 2026-06-12): in-place
|
|
12
|
+
* interrupt keeps the proc warm (no --resume); kill only for
|
|
13
|
+
* ghosts / detached background shells / unverifiable state.
|
|
13
14
|
* 3. pm.drainQueue() — rejects queued pendings with
|
|
14
15
|
* err.code='INTERRUPTED' so the abort-grace classifier
|
|
15
16
|
* suppresses error replies on the way out.
|
|
16
17
|
* 4. Clear ✍ reactions on already-autosteered messages from
|
|
17
18
|
* this turn (now dead context).
|
|
18
|
-
* 5. Acknowledge
|
|
19
|
+
* 5. Acknowledge with a 👍 reaction on the stop message when
|
|
20
|
+
* something was stopped; silence otherwise. Never text.
|
|
19
21
|
*
|
|
20
22
|
* Returns true when the message was handled as an abort, false
|
|
21
23
|
* otherwise. Caller short-circuits on true.
|
|
@@ -33,6 +35,9 @@ function createHandleAbort({
|
|
|
33
35
|
clearAutosteeredReactions,
|
|
34
36
|
getSessionKey,
|
|
35
37
|
botName,
|
|
38
|
+
// Cancel-cheap (spec Finding 5): delay before the second background-shell
|
|
39
|
+
// probe — catches a shell whose TUI mode-line hadn't rendered at probe #1.
|
|
40
|
+
dualProbeDelayMs = 1000,
|
|
36
41
|
logger = console,
|
|
37
42
|
} = {}) {
|
|
38
43
|
|
|
@@ -100,21 +105,59 @@ function createHandleAbort({
|
|
|
100
105
|
// classifier suppresses their error replies AND each turn's finally clears
|
|
101
106
|
// its reactor + typing), THEN stop the live work.
|
|
102
107
|
pm.drainQueue(sessionKey, 'INTERRUPTED');
|
|
108
|
+
// Cancel-cheap tiered gate (docs/0.13-cancel-efficiency-and-delete-trigger-
|
|
109
|
+
// spec.md, locked 2026-06-12; supersedes the 2026-06-04 always-kill
|
|
110
|
+
// decision). kill→--resume is the resume-death-race path AND a full
|
|
111
|
+
// re-prefill on aged sessions, so the cli backend now interrupts IN PLACE
|
|
112
|
+
// (C-c; claude stays resident, next message reuses the warm proc — live-
|
|
113
|
+
// verified on 2.1.173: subagents die with the turn) and kills ONLY when
|
|
114
|
+
// an in-place interrupt genuinely can't reach the work:
|
|
115
|
+
// - a GHOST busy-state (no pending turn but still streaming — interrupt
|
|
116
|
+
// can't clear its feedback; the close-drain can),
|
|
117
|
+
// - detached run_in_background shells (they survive C-c — live-verified;
|
|
118
|
+
// the pane scrape false-negatives, so cross-check the bg-work watchdog
|
|
119
|
+
// and dual-probe, and FAIL TOWARD KILL when unverifiable).
|
|
120
|
+
let cancelMode = 'none';
|
|
121
|
+
let killReason = null;
|
|
103
122
|
if (hadActive && proc && proc.backend === 'cli') {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
123
|
+
let mustKill = false;
|
|
124
|
+
if (!proc.inFlight) {
|
|
125
|
+
// hadActive came from the busy probe with no pending turn = ghost.
|
|
126
|
+
mustKill = true; killReason = 'ghost';
|
|
127
|
+
} else if (typeof proc.probeBusyState !== 'function') {
|
|
128
|
+
mustKill = true; killReason = 'no-probe';
|
|
129
|
+
} else {
|
|
130
|
+
try {
|
|
131
|
+
const p1 = busyProbe || await proc.probeBusyState();
|
|
132
|
+
let bg = !!p1?.backgroundShell;
|
|
133
|
+
if (!bg && typeof proc.hasActiveBackgroundWork === 'function'
|
|
134
|
+
&& await proc.hasActiveBackgroundWork()) {
|
|
135
|
+
bg = true;
|
|
136
|
+
}
|
|
137
|
+
if (!bg) {
|
|
138
|
+
await new Promise((r) => setTimeout(r, dualProbeDelayMs));
|
|
139
|
+
const p2 = await proc.probeBusyState();
|
|
140
|
+
bg = !!p2?.backgroundShell;
|
|
141
|
+
}
|
|
142
|
+
if (bg) { mustKill = true; killReason = 'background-shell'; }
|
|
143
|
+
} catch (err) {
|
|
144
|
+
logger.error?.(`[${botName}] cancel bg-probe failed: ${err.message}`);
|
|
145
|
+
mustKill = true; killReason = 'probe-failed';
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (mustKill) {
|
|
149
|
+
cancelMode = 'kill';
|
|
150
|
+
await pm.kill(sessionKey, 'abort').catch((err) =>
|
|
151
|
+
logger.error?.(`[${botName}] abort kill failed: ${err.message}`));
|
|
152
|
+
} else {
|
|
153
|
+
cancelMode = 'interrupt';
|
|
154
|
+
await pm.interrupt(sessionKey).catch((err) =>
|
|
155
|
+
logger.error?.(`[${botName}] interrupt failed: ${err.message}`));
|
|
156
|
+
}
|
|
115
157
|
} else {
|
|
116
158
|
// SDK (or nothing active): non-destructive interrupt cancels the in-flight
|
|
117
159
|
// Query turn WITHOUT tearing down the Query (cheap to reuse next message).
|
|
160
|
+
if (hadActive) cancelMode = 'interrupt';
|
|
118
161
|
await pm.interrupt(sessionKey).catch((err) =>
|
|
119
162
|
logger.error?.(`[${botName}] interrupt failed: ${err.message}`));
|
|
120
163
|
}
|
|
@@ -123,6 +166,10 @@ function createHandleAbort({
|
|
|
123
166
|
logEvent('abort-requested', {
|
|
124
167
|
chat_id: chatId, user_id: msg.from?.id || null,
|
|
125
168
|
had_active: hadActive,
|
|
169
|
+
// Cancel-cheap soak signals: which tier fired, and why a kill was chosen
|
|
170
|
+
// (ghost / background-shell / no-probe / probe-failed / null).
|
|
171
|
+
cancel_mode: cancelMode,
|
|
172
|
+
kill_reason: killReason,
|
|
126
173
|
killed_background_shell: killedBackgroundShell,
|
|
127
174
|
// "Stop" incident forensics: the raw busy-probe signals at decision
|
|
128
175
|
// time. Lets us query, across real aborts, where the esc-hint /
|
|
@@ -140,35 +187,22 @@ function createHandleAbort({
|
|
|
140
187
|
trigger: cleanText.slice(0, 40),
|
|
141
188
|
});
|
|
142
189
|
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
// background shell → "Stopped the background task"; neither →
|
|
160
|
-
// "Nothing to stop".
|
|
161
|
-
const reply = hadActive ? strs.stopped
|
|
162
|
-
: killedBackgroundShell ? strs.bgStopped
|
|
163
|
-
: strs.nothing;
|
|
164
|
-
try {
|
|
165
|
-
await tg(bot, 'sendMessage', {
|
|
166
|
-
chat_id: chatId, text: reply,
|
|
167
|
-
reply_parameters: { message_id: msg.message_id, allow_sending_without_reply: true },
|
|
168
|
-
...(threadId && { message_thread_id: threadId }),
|
|
169
|
-
}, { source: 'abort-ack', botName });
|
|
170
|
-
} catch (err) {
|
|
171
|
-
logger.error?.(`[${botName}] abort-ack send failed: ${err.message}`);
|
|
190
|
+
// Ack (locked design 2026-06-12, Ivan): a 👍 reaction on the user's stop
|
|
191
|
+
// message when something was actually stopped — NEVER a text reply. The
|
|
192
|
+
// old "Stopped." text was eventually-true at best (the interrupt settles
|
|
193
|
+
// up to graceMs later) and chat noise at worst; the reaction is just
|
|
194
|
+
// "got it, stopping" and is language-neutral. Nothing-to-stop → complete
|
|
195
|
+
// silence (a 👍 there would lie).
|
|
196
|
+
if (hadActive || killedBackgroundShell) {
|
|
197
|
+
try {
|
|
198
|
+
await tg(bot, 'setMessageReaction', {
|
|
199
|
+
chat_id: chatId,
|
|
200
|
+
message_id: msg.message_id,
|
|
201
|
+
reaction: [{ type: 'emoji', emoji: '👍' }],
|
|
202
|
+
}, { source: 'abort-ack', botName });
|
|
203
|
+
} catch (err) {
|
|
204
|
+
logger.error?.(`[${botName}] abort-ack reaction failed: ${err.message}`);
|
|
205
|
+
}
|
|
172
206
|
}
|
|
173
207
|
return true;
|
|
174
208
|
};
|
|
@@ -926,7 +926,17 @@ class CliProcess extends Process {
|
|
|
926
926
|
// "Channels (experimental) messages from server:polygram-bridge inject
|
|
927
927
|
// directly in this session · …". Keep the 2.1.158 text too so a
|
|
928
928
|
// POLYGRAM_CLAUDE_BIN override to an older binary still gates correctly.
|
|
929
|
-
|
|
929
|
+
//
|
|
930
|
+
// 2026-06-12 (caught by the cancel-cheap E2E before prod): in 2.1.173
|
|
931
|
+
// the banner lives in a COLLAPSIBLE notice list — with ≥3 notices the
|
|
932
|
+
// pane shows "+N more · /status" and the banner is hidden, stalling a
|
|
933
|
+
// banner-only gate into a false CHANNELS_DIALOG_TIMEOUT. An interactive
|
|
934
|
+
// prompt footer ("(shift+tab to cycle)" / "? for shortcuts") with no
|
|
935
|
+
// pending dialog is equally READY: the gate's job is dialog navigation;
|
|
936
|
+
// channel liveness is separately guaranteed by mcp-ready (send() gate)
|
|
937
|
+
// + the delivery watchdog. Dialog panes render "Enter to confirm"
|
|
938
|
+
// instead of the footer, so the footer can't match mid-dialog.
|
|
939
|
+
readySignal: /(?:Listening for channel messages from:|Channels \(experimental\) messages from) server:polygram-bridge|shift\+tab to cycle|\? for shortcuts/i,
|
|
930
940
|
timeoutCode: 'CHANNELS_DIALOG_TIMEOUT',
|
|
931
941
|
// Progress-aware gate (shumorobot General incident 2026-05-30): a
|
|
932
942
|
// cold spawn that's mid-download (runtime fetch, "24%" progress bar)
|
|
@@ -2147,6 +2157,13 @@ class CliProcess extends Process {
|
|
|
2147
2157
|
async interrupt() {
|
|
2148
2158
|
if (this.closed) return;
|
|
2149
2159
|
if (!this.tmuxSession) return;
|
|
2160
|
+
// Cancel-cheap C2 (spec Finding 7): a cancel is already in flight — a
|
|
2161
|
+
// SECOND C-c would land at the now-idle prompt, which is claude's exit
|
|
2162
|
+
// chord ("press ctrl+c again to exit") and would convert the cheap cancel
|
|
2163
|
+
// into an accidental process exit. Also: resetting the grace timer would
|
|
2164
|
+
// DELAY the synthetic resolution for a user double-tapping "stop".
|
|
2165
|
+
// Idempotent no-op instead.
|
|
2166
|
+
if (this._interruptGraceTimer) return;
|
|
2150
2167
|
// tmux SIGINT — hard interrupt for the running turn.
|
|
2151
2168
|
try {
|
|
2152
2169
|
await this.runner.sendControl(this.tmuxSession, 'C-c');
|
|
@@ -2157,18 +2174,37 @@ class CliProcess extends Process {
|
|
|
2157
2174
|
this.emit('interrupt-applied', { backend: this.backend });
|
|
2158
2175
|
this._logEvent('interrupt-applied', {});
|
|
2159
2176
|
|
|
2177
|
+
// Cancel-cheap C1 — the spec's O2 BLOCKER: the cancelled work's inputs
|
|
2178
|
+
// must never re-deliver. The grace below synthesizes the resolution
|
|
2179
|
+
// WITHOUT _finalizeTurn, so without this, an autosteer/fold entry stays
|
|
2180
|
+
// 'written' and a LATER cycle-end sweep declares it dropped →
|
|
2181
|
+
// drop-redeliver re-injects the user's CANCELLED message minutes later.
|
|
2182
|
+
// 'cancelled' is terminal: the sweep only targets 'written', and
|
|
2183
|
+
// _ledgerTransition clears the entry's drop/watchdog timers.
|
|
2184
|
+
for (const [id, e] of this.inputLedger) {
|
|
2185
|
+
if (e.state === 'written' || e.state === 'seen') {
|
|
2186
|
+
this._ledgerTransition(id, 'cancelled');
|
|
2187
|
+
this._logEvent('cli-input-cancelled', { turn_id: id, source: e.source });
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2160
2191
|
// Review P3 C8: after Ctrl-C, Claude may or may not call reply with an
|
|
2161
2192
|
// "I was interrupted" message. If it doesn't (5s grace), resolve pending
|
|
2162
2193
|
// turns with subtype 'interrupted' instead of letting them wait the full
|
|
2163
|
-
// 10-min hardTimer.
|
|
2164
|
-
if (this._interruptGraceTimer) clearTimeout(this._interruptGraceTimer);
|
|
2194
|
+
// 10-min hardTimer.
|
|
2165
2195
|
this._interruptGraceTimer = setTimeout(() => {
|
|
2166
2196
|
let resolvedAny = false;
|
|
2167
2197
|
for (const [turnId, pending] of this.pendingTurns) {
|
|
2168
2198
|
// Synthesize an interrupted resolution: empty text, 'interrupted' subtype.
|
|
2199
|
+
// Cancel-cheap C3: clear ALL per-pending machinery (mirrors
|
|
2200
|
+
// _finalizeTurn) — stray timers/listeners on the kept-warm proc are
|
|
2201
|
+
// exactly what the cheap-cancel design must not leak.
|
|
2169
2202
|
if (pending.quietTimer) clearTimeout(pending.quietTimer);
|
|
2170
2203
|
if (pending.hardTimer) clearTimeout(pending.hardTimer);
|
|
2171
|
-
|
|
2204
|
+
if (pending.absoluteTimer) clearTimeout(pending.absoluteTimer);
|
|
2205
|
+
if (pending._stopGraceTimer) clearTimeout(pending._stopGraceTimer);
|
|
2206
|
+
if (pending._activityQuietTimer) clearTimeout(pending._activityQuietTimer);
|
|
2207
|
+
if (pending._onStop) { this.off('stop-hook', pending._onStop); pending._onStop = null; }
|
|
2172
2208
|
this.pendingTurns.delete(turnId);
|
|
2173
2209
|
const qIdx = this.pendingQueue.findIndex(e => e.turnId === turnId);
|
|
2174
2210
|
if (qIdx >= 0) this.pendingQueue.splice(qIdx, 1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.12.0-rc.
|
|
3
|
+
"version": "0.12.0-rc.39",
|
|
4
4
|
"description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
|
|
5
5
|
"main": "lib/ipc/client.js",
|
|
6
6
|
"bin": {
|