Compare commits

..
Author SHA1 Message Date
Cursor Agentand陈大猫 c849bf6a73 fix(plugins): preserve overwrite stash across retries and prefix deletes
Do not replace an existing restore stash on later puts, and clear matching
stash entries when deleteByKeyPrefix wipes sync credentials on disconnect.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:36:59 +00:00
Cursor Agentand陈大猫 0d9c4a6e02 fix(plugins): keep overwrite stash until restore write succeeds
Only drop the prior plaintext after encrypt+upsert succeeds so a failed
restore can still retry and recover the saved SecretRef.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:32:08 +00:00
Cursor Agentand陈大猫 acd5700284 fix(plugins): host-own sync provider bindings and clear failed stashes
Move provider→plugin bindings into a dedicated DB table outside
plugin-writable secrets, validate provider namespace ownership, and drop
overwrite stashes when replacement writes fail.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:27:26 +00:00
Cursor Agentand陈大猫 05b33b5521 fix(plugins): make sync secret overwrite stash opt-in
Only the syncPutSecret path stashes prior plaintext/SecretRef so ordinary
secrets.set overwrites do not leave credentials in process memory.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:18:26 +00:00
Cursor Agentand陈大猫 786e5b48ef fix(plugins): restore overwritten sync secrets on rejected reconnect
Stash prior plaintext and SecretRef id on overwrite, restore them when
connect fails, and discard the stash after a successful reconnect so
saved provider credentials keep resolving.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:12:47 +00:00
Cursor Agentand陈大猫 6414c9e177 fix(plugins): address residual Codex sync secret and backup gaps
Retain provider→plugin secret bindings so disconnect can wipe credentials
after a plugin is disabled, keep overwritten secrets on failed reconnect,
and snapshot live-empty sidecars in protective backups.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:05:39 +00:00
20 changed files with 130 additions and 3252 deletions

No files matched your search

+5 -69
View File
@@ -196,16 +196,9 @@ jobs:
}
if (event === 'issues') {
const issue = context.payload.issue;
const decision = auto.decideIssuesEventRoute({
action: context.payload.action,
labels: issue?.labels || [],
actorLogin: context.payload.sender?.login,
botLogins: process.env.ISSUE_BOT_LOGINS,
});
return set(decision.kind, {
issue_number: String(issue.number),
reason: decision.reason,
return set('issue_classify', {
issue_number: String(context.payload.issue.number),
reason: `issues:${context.payload.action}`,
});
}
@@ -271,7 +264,6 @@ jobs:
hasOpenBotPull: Boolean(pull),
hasOpenRelatedPull: Boolean(relatedPull),
body: comment.body,
labels,
});
if (refined.kind === 'issue_classify') {
return set('issue_classify', {
@@ -357,41 +349,6 @@ jobs:
return set('skip', { reason: `unhandled ${event}` });
ready_for_human_handoff:
name: Hand reopened issue to maintainers
needs: route
if: needs.route.outputs.kind == 'ready_for_human_handoff'
runs-on: ubuntu-latest
concurrency:
group: cursor-source-issue-${{ needs.route.outputs.issue_number || github.run_id }}
cancel-in-progress: false
permissions:
contents: read
issues: write
steps:
- name: Checkout helpers
uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Apply ready-for-human handoff
uses: actions/github-script@v9
env:
ISSUE_NUMBER: ${{ needs.route.outputs.issue_number }}
TRUSTED_COMMENT_AUTHORS: ${{ vars.AUTOMATION_OWN_ACTORS || 'binaricat,netcatty-bot,github-actions[bot]' }}
with:
github-token: ${{ secrets.TRIAGE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const auto = require(`${process.env.GITHUB_WORKSPACE}/scripts/cursor-automation.cjs`);
await auto.applyReadyForHumanHandoff({
github,
context,
issueNumber: process.env.ISSUE_NUMBER,
dedupeMarker: auto.REOPEN_HANDOFF_MARKER,
trustedCommentAuthors: process.env.TRUSTED_COMMENT_AUTHORS,
});
cleanup_source_issue:
name: Clean source issue state
needs: route
@@ -2136,7 +2093,6 @@ jobs:
".cursor-runtime/followup-pending-snapshots.json",
".cursor-runtime/followup-verify-result.json",
".cursor-runtime/followup-test-comparison.json",
".cursor-runtime/followup-base-tests.log",
".cursor-runtime/followup-candidate-lint.log",
".cursor-runtime/followup-candidate-tests.log",
".cursor-runtime/followup-candidate-build.log",
@@ -2158,7 +2114,6 @@ jobs:
.cursor-runtime/followup-pending-snapshots.json
.cursor-runtime/followup-verify-result.json
.cursor-runtime/followup-test-comparison.json
.cursor-runtime/followup-base-tests.log
.cursor-runtime/followup-candidate-lint.log
.cursor-runtime/followup-candidate-tests.log
.cursor-runtime/followup-candidate-build.log
@@ -3424,7 +3379,6 @@ jobs:
".cursor-runtime/implement-pr-body.md",
".cursor-runtime/verify-result.json",
".cursor-runtime/test-comparison.json",
".cursor-runtime/base-tests.log",
".cursor-runtime/candidate-lint.log",
".cursor-runtime/candidate-tests.log",
".cursor-runtime/candidate-build.log",
@@ -3446,7 +3400,6 @@ jobs:
.cursor-runtime/implement-pr-body.out.md
.cursor-runtime/verify-result.json
.cursor-runtime/test-comparison.json
.cursor-runtime/base-tests.log
.cursor-runtime/candidate-lint.log
.cursor-runtime/candidate-tests.log
.cursor-runtime/candidate-build.log
@@ -3456,14 +3409,6 @@ jobs:
if: steps.patch.outputs.artifact_ready == 'true' && steps.verify.outputs.passed != 'true'
run: |
echo "Candidate patch was preserved, but candidate-specific verification failed." >&2
if [[ -f .cursor-runtime/test-comparison.json ]]; then
echo "Test comparison:" >&2
cat .cursor-runtime/test-comparison.json >&2 || true
fi
if [[ -f .cursor-runtime/verify-result.json ]]; then
echo "Verify result:" >&2
cat .cursor-runtime/verify-result.json >&2 || true
fi
exit 1
- name: Mark needs human on implement failure
@@ -4867,7 +4812,6 @@ jobs:
".cursor-runtime/fix.patch",
".cursor-runtime/fix-verify-result.json",
".cursor-runtime/fix-test-comparison.json",
".cursor-runtime/fix-base-tests.log",
".cursor-runtime/fix-candidate-lint.log",
".cursor-runtime/fix-candidate-tests.log",
".cursor-runtime/fix-candidate-build.log",
@@ -4887,7 +4831,6 @@ jobs:
.cursor-runtime/fix.patch
.cursor-runtime/fix-verify-result.json
.cursor-runtime/fix-test-comparison.json
.cursor-runtime/fix-base-tests.log
.cursor-runtime/fix-candidate-lint.log
.cursor-runtime/fix-candidate-tests.log
.cursor-runtime/fix-candidate-build.log
@@ -4897,14 +4840,6 @@ jobs:
if: steps.codex.outputs.action == 'fix' && steps.fixpatch.outputs.artifact_ready == 'true' && steps.fixverify.outputs.passed != 'true'
run: |
echo "Codex fix patch was preserved, but candidate-specific verification failed." >&2
if [[ -f .cursor-runtime/fix-test-comparison.json ]]; then
echo "Test comparison:" >&2
cat .cursor-runtime/fix-test-comparison.json >&2 || true
fi
if [[ -f .cursor-runtime/fix-verify-result.json ]]; then
echo "Verify result:" >&2
cat .cursor-runtime/fix-verify-result.json >&2 || true
fi
exit 1
- name: Mark needs human when no fix produced
@@ -5361,7 +5296,8 @@ jobs:
},
);
const forceRetry =
process.env.GITHUB_EVENT_NAME === 'workflow_dispatch'; if (
process.env.GITHUB_EVENT_NAME === 'workflow_dispatch';
if (
!forceRetry &&
auto.shouldSkipExternalCodexRerequest({
existingComments: comments,
-2
View File
@@ -443,8 +443,6 @@ export const enAiMessages: Messages = {
'ai.chat.newChat': 'New Chat',
'ai.chat.allSessions': 'All Sessions',
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
'ai.chat.jumpNav': 'Jump to message',
'ai.chat.jumpUntitled': '(empty message)',
'ai.chat.usedTools': 'Tools used: {n}',
'ai.chat.loadMoreSessions': 'Load more sessions ({n} more)',
'ai.chat.noSessions': 'No previous sessions',
-2
View File
@@ -395,8 +395,6 @@ export const ruAiMessages: Messages = {
'ai.chat.newChat': 'Новый чат',
'ai.chat.allSessions': 'Все сессии',
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
'ai.chat.jumpNav': 'Перейти к сообщению',
'ai.chat.jumpUntitled': '(пустое сообщение)',
'ai.chat.usedTools': 'Использовано инструментов: {n}',
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
'ai.chat.noSessions': 'Предыдущих сессий нет',
-2
View File
@@ -443,8 +443,6 @@ export const zhCNAiMessages: Messages = {
'ai.chat.newChat': '新对话',
'ai.chat.allSessions': '所有会话',
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
'ai.chat.jumpNav': '跳转到消息',
'ai.chat.jumpUntitled': '(空消息)',
'ai.chat.usedTools': '已使用 {n} 个工具',
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
'ai.chat.noSessions': '没有历史会话',
-2
View File
@@ -442,8 +442,6 @@ export const zhTWAiMessages: Messages = {
'ai.chat.newChat': '新對話',
'ai.chat.allSessions': '所有工作階段',
'ai.chat.loadEarlierMessages': '載入更早的訊息(還有 {n} 條)',
'ai.chat.jumpNav': '跳轉到訊息',
'ai.chat.jumpUntitled': '(空訊息)',
'ai.chat.usedTools': '已使用 {n} 個工具',
'ai.chat.loadMoreSessions': '載入更多工作階段(還有 {n} 條)',
'ai.chat.noSessions': '沒有歷史工作階段',
+3 -8
View File
@@ -25,17 +25,12 @@ export const ConversationContent = ({ className, ...props }: ConversationContent
/>
);
export const ConversationScrollButton = ({
className,
onClick,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
export const ConversationScrollButton = ({ className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
const handleClick = useCallback(() => {
scrollToBottom();
}, [onClick, scrollToBottom]);
}, [scrollToBottom]);
if (isAtBottom) return null;
-142
View File
@@ -1,142 +0,0 @@
/**
* Floating jump list for long AI chat sessions (user-turn TOC).
*/
import { ListTree, X } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useStickToBottomContext } from 'use-stick-to-bottom';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { ChatJumpEntry } from '../../domain/chatJumpNav';
import { cn } from '../../lib/utils';
export interface ChatJumpNavProps {
entries: ChatJumpEntry[];
activeMessageId: string | null;
/** When true, ignore transient isAtBottom flips from streaming resize. */
isStreaming?: boolean;
onSelect: (messageId: string) => void;
/** Fired after the user leaves the jump target and returns to the bottom. */
onReleasePin?: () => void;
}
const ChatJumpNav: React.FC<ChatJumpNavProps> = ({
entries,
activeMessageId,
isStreaming = false,
onSelect,
onReleasePin,
}) => {
const { t } = useI18n();
const { stopScroll, isAtBottom } = useStickToBottomContext();
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
// Only release after the viewport has left the bottom; avoids clearing a pin
// on the same tick as select while isAtBottom is still true.
const leftBottomWhilePinnedRef = useRef(false);
useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
if (target && rootRef.current && !rootRef.current.contains(target)) {
setOpen(false);
}
};
document.addEventListener('pointerdown', onPointerDown);
return () => document.removeEventListener('pointerdown', onPointerDown);
}, [open]);
useEffect(() => {
if (!activeMessageId) {
leftBottomWhilePinnedRef.current = false;
return;
}
if (!isAtBottom) {
leftBottomWhilePinnedRef.current = true;
return;
}
if (leftBottomWhilePinnedRef.current) {
// Streaming content growth / smooth resize can flip isAtBottom without the
// user intending to leave the jump target; keep the pin until streaming ends
// or they explicitly scroll to bottom via the scroll button.
if (isStreaming) return;
leftBottomWhilePinnedRef.current = false;
onReleasePin?.();
return;
}
// Jump target was already in the bottom window, so the viewport never left
// isAtBottom. Clear the pin after scrollIntoView has had a chance to run.
if (isStreaming) return;
const timer = window.setTimeout(() => {
onReleasePin?.();
}, 100);
return () => window.clearTimeout(timer);
}, [activeMessageId, isAtBottom, isStreaming, onReleasePin]);
const handleSelect = useCallback((messageId: string) => {
stopScroll();
onSelect(messageId);
setOpen(false);
}, [onSelect, stopScroll]);
if (entries.length === 0) return null;
return (
<div ref={rootRef} className="absolute top-3 right-3 z-20 flex flex-col items-end gap-1.5">
<button
type="button"
className={cn(
'h-7 w-7 rounded-full border border-border/40 bg-background/90 backdrop-blur-sm',
'flex items-center justify-center shadow-sm',
'text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-pointer',
open && 'text-foreground bg-muted',
)}
aria-label={t('ai.chat.jumpNav')}
aria-expanded={open}
title={t('ai.chat.jumpNav')}
onClick={() => setOpen((value) => !value)}
>
{open ? <X size={14} /> : <ListTree size={14} />}
</button>
{open && (
<div
className={cn(
'w-[min(220px,calc(100vw-2rem))] max-h-[min(320px,50vh)] overflow-y-auto',
'rounded-md border border-border/50 bg-background/95 backdrop-blur-sm shadow-md',
'py-1',
)}
role="listbox"
aria-label={t('ai.chat.jumpNav')}
>
{entries.map((entry) => {
const selected = entry.messageId === activeMessageId;
return (
<button
key={entry.messageId}
type="button"
role="option"
aria-selected={selected}
className={cn(
'flex w-full items-start gap-2 px-2.5 py-1.5 text-left text-[12px] leading-snug',
'hover:bg-muted/70 transition-colors cursor-pointer',
selected
? 'bg-muted text-foreground'
: 'text-foreground/80',
)}
onClick={() => handleSelect(entry.messageId)}
>
<span className="shrink-0 tabular-nums text-muted-foreground/70 w-4 text-right">
{entry.index}
</span>
<span className="min-w-0 flex-1 truncate">{entry.label}</span>
</button>
);
})}
</div>
)}
</div>
);
};
export default ChatJumpNav;
-57
View File
@@ -132,63 +132,6 @@ test("ChatMessageList only renders the recent message batch by default", () => {
assert.match(markup, /message-59/);
});
test("ChatMessageList exposes jump navigation once there are enough user turns", () => {
const messages: ChatMessage[] = [
{ id: "u1", role: "user", content: "first turn", timestamp: 1 },
{ id: "a1", role: "assistant", content: "ok", timestamp: 2 },
{ id: "u2", role: "user", content: "second turn", timestamp: 3 },
{ id: "a2", role: "assistant", content: "ok", timestamp: 4 },
{ id: "u3", role: "user", content: "third turn", timestamp: 5 },
{ id: "a3", role: "assistant", content: "ok", timestamp: 6 },
];
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(ChatMessageList, { messages }),
),
),
);
assert.match(markup, /aria-label="Jump to message"/);
assert.match(markup, /id="ai-chat-msg-u1"/);
assert.match(markup, /id="ai-chat-msg-u3"/);
});
test("jump pin release does not reset the loaded message tail", () => {
const source = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
const releaseHandler = source.match(
/const handleReleaseJumpPin = useCallback\(\(\) => \{[\s\S]*?\}, \[\]\);/,
)?.[0] ?? "";
assert.match(releaseHandler, /setActiveJumpMessageId\(null\)/);
assert.match(releaseHandler, /setPendingJumpMessageId\(null\)/);
assert.doesNotMatch(releaseHandler, /setRenderedTailCount/);
});
test("load earlier advances from the effective pinned tail", () => {
const source = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
assert.match(
source,
/setRenderedTailCount\(\(count\) =>\s*Math\.max\(count, effectiveTailCount\) \+ MESSAGE_RENDER_STEP\)/,
);
});
test("jump pin ignores streaming isAtBottom flips and releases on scroll button", () => {
const jumpSource = readFileSync(new URL("./ChatJumpNav.tsx", import.meta.url), "utf8");
const listSource = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
assert.match(jumpSource, /isStreaming\?: boolean/);
assert.match(jumpSource, /if \(isStreaming\) return;/);
assert.match(jumpSource, /window\.setTimeout/);
assert.match(listSource, /isStreaming=\{\!\!isStreaming\}/);
assert.match(listSource, /<ConversationScrollButton onClick=\{handleReleaseJumpPin\} \/>/);
});
test("ChatMessageList renders Codex activities and actual usage", () => {
const messages: ChatMessage[] = [{
id: "assistant-activity",
+5 -63
View File
@@ -73,12 +73,6 @@ import {
getAIPanelProfilerProps,
isAIPanelDiagnosticPartHidden,
} from './aiPanelDiagnostics';
import {
buildChatJumpEntries,
chatMessageDomId,
resolveTailCountForJumpTarget,
} from '../../domain/chatJumpNav';
import ChatJumpNav from './ChatJumpNav';
interface ChatMessageListProps {
messages: ChatMessage[];
@@ -360,13 +354,9 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
const hideMarkdown = isAIPanelDiagnosticPartHidden('markdown', hiddenParts);
const hideToolCalls = isAIPanelDiagnosticPartHidden('toolcalls', hiddenParts);
const [renderedTailCount, setRenderedTailCount] = useState(MESSAGE_RENDER_BATCH);
const [activeJumpMessageId, setActiveJumpMessageId] = useState<string | null>(null);
const [pendingJumpMessageId, setPendingJumpMessageId] = useState<string | null>(null);
useEffect(() => {
setRenderedTailCount(MESSAGE_RENDER_BATCH);
setActiveJumpMessageId(null);
setPendingJumpMessageId(null);
}, [activeSessionId]);
const visibleMessages = useMemo(
@@ -374,47 +364,11 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
[messages],
);
// While a jump target is active, re-resolve the tail against the current list
// so streaming appends cannot slide the window past the selected message.
const effectiveTailCount = activeJumpMessageId
? resolveTailCountForJumpTarget(visibleMessages, activeJumpMessageId, renderedTailCount)
: renderedTailCount;
const hiddenMessageCount = Math.max(0, visibleMessages.length - effectiveTailCount);
const hiddenMessageCount = Math.max(0, visibleMessages.length - renderedTailCount);
const displayedMessages = hiddenMessageCount > 0
? visibleMessages.slice(-effectiveTailCount)
? visibleMessages.slice(-renderedTailCount)
: visibleMessages;
const jumpEntries = useMemo(
() => buildChatJumpEntries(visibleMessages, {
emptyLabel: t('ai.chat.jumpUntitled'),
}),
[t, visibleMessages],
);
const handleJumpToMessage = useCallback((messageId: string) => {
setActiveJumpMessageId(messageId);
// Persist the expanded window so releasing the pin (or a spurious isAtBottom
// flip) cannot unmount the jump target. Load-earlier progress is preserved
// because we never reset renderedTailCount on pin release.
setRenderedTailCount((count) =>
resolveTailCountForJumpTarget(visibleMessages, messageId, count));
setPendingJumpMessageId(messageId);
}, [visibleMessages]);
const handleReleaseJumpPin = useCallback(() => {
setActiveJumpMessageId(null);
setPendingJumpMessageId(null);
}, []);
useEffect(() => {
if (!pendingJumpMessageId) return;
const target = document.getElementById(chatMessageDomId(pendingJumpMessageId));
if (!target) return;
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
setPendingJumpMessageId(null);
}, [displayedMessages, pendingJumpMessageId]);
const resolvedToolCallIds = new Set(
displayedMessages
.filter((m) => m.role === 'tool')
@@ -529,8 +483,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
{hiddenMessageCount > 0 && (
<button
type="button"
onClick={() => setRenderedTailCount((count) =>
Math.max(count, effectiveTailCount) + MESSAGE_RENDER_STEP)}
onClick={() => setRenderedTailCount((count) => count + MESSAGE_RENDER_STEP)}
className="w-full py-2 text-center text-[12px] text-muted-foreground/50 hover:text-muted-foreground transition-colors cursor-pointer"
>
{t('ai.chat.loadEarlierMessages').replace('{n}', String(hiddenMessageCount))}
@@ -638,11 +591,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
const isThisStreaming = isStreaming && isLastAssistant;
return (
<Message
key={message.id}
id={chatMessageDomId(message.id)}
from={message.role}
>
<Message key={message.id} from={message.role}>
<MessageContent from={message.role}>
{/* Thinking block */}
{!isUser && message.thinking && (
@@ -884,14 +833,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
</div>
)}
</ConversationContent>
<ChatJumpNav
entries={jumpEntries}
activeMessageId={activeJumpMessageId}
isStreaming={!!isStreaming}
onSelect={handleJumpToMessage}
onReleasePin={handleReleaseJumpPin}
/>
<ConversationScrollButton onClick={handleReleaseJumpPin} />
<ConversationScrollButton />
</Conversation>
{/* Image preview lightbox */}
@@ -1476,8 +1476,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
) {
return true;
}
// No xterm selection: pass Ctrl+C through for SIGINT, and Cmd+C for
// Kitty Super+C (nested TUIs). Other copy chords stay a safe no-op.
// When copy is bound specifically to Ctrl+C and there is no text
// selected, pass the event through so xterm can send SIGINT.
const shouldForwardCopyToTerminal =
shouldPassThroughCopyShortcut(action, term.hasSelection(), e);
if (shouldForwardCopyToTerminal && !kittySequenceForKeyDown) return true;
@@ -3,7 +3,6 @@ import test from "node:test";
import {
isPlainCtrlCInterruptChord,
isPlainMetaCCopyChord,
shouldPassThroughCopyShortcut,
} from "./terminalCopyShortcut.ts";
@@ -28,20 +27,10 @@ test("plain Ctrl+C copy with no selection passes through for SIGINT", () => {
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
});
test("plain Cmd+C copy with no selection passes through for Kitty Super+C", () => {
const event = keyboardEvent("c", "KeyC", { metaKey: true });
assert.equal(isPlainMetaCCopyChord(event), true);
assert.equal(isPlainCtrlCInterruptChord(event), false);
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
});
test("copy shortcut does not pass through when text is selected", () => {
const ctrlC = keyboardEvent("c", "KeyC", { ctrlKey: true });
const cmdC = keyboardEvent("c", "KeyC", { metaKey: true });
const event = keyboardEvent("c", "KeyC", { ctrlKey: true });
assert.equal(shouldPassThroughCopyShortcut("copy", true, ctrlC), false);
assert.equal(shouldPassThroughCopyShortcut("copy", true, cmdC), false);
assert.equal(shouldPassThroughCopyShortcut("copy", true, event), false);
});
test("copy shortcut does not pass through for shifted or alternate chords", () => {
@@ -49,26 +38,14 @@ test("copy shortcut does not pass through for shifted or alternate chords", () =
shouldPassThroughCopyShortcut("copy", false, keyboardEvent("C", "KeyC", { ctrlKey: true, shiftKey: true })),
false,
);
assert.equal(
shouldPassThroughCopyShortcut("copy", false, keyboardEvent("C", "KeyC", { metaKey: true, shiftKey: true })),
false,
);
assert.equal(
shouldPassThroughCopyShortcut("copy", false, keyboardEvent("l", "KeyL", { ctrlKey: true })),
false,
);
assert.equal(
shouldPassThroughCopyShortcut("copy", false, keyboardEvent("c", "KeyC", { metaKey: true, ctrlKey: true })),
false,
);
assert.equal(
shouldPassThroughCopyShortcut("paste", false, keyboardEvent("c", "KeyC", { ctrlKey: true })),
false,
);
assert.equal(
shouldPassThroughCopyShortcut("paste", false, keyboardEvent("c", "KeyC", { metaKey: true })),
false,
);
});
test("plain Ctrl+C copy passthrough follows the physical C key on non-Latin layouts", () => {
@@ -77,10 +54,3 @@ test("plain Ctrl+C copy passthrough follows the physical C key on non-Latin layo
assert.equal(isPlainCtrlCInterruptChord(event), true);
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
});
test("plain Cmd+C copy passthrough follows the physical C key on non-Latin layouts", () => {
const event = keyboardEvent("\u0441", "KeyC", { metaKey: true });
assert.equal(isPlainMetaCCopyChord(event), true);
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
});
@@ -3,43 +3,18 @@ type CopyShortcutKeyEvent = Pick<
"key" | "code" | "ctrlKey" | "shiftKey" | "altKey" | "metaKey"
>;
function isPhysicalCKey(e: CopyShortcutKeyEvent): boolean {
return e.key.toLowerCase() === "c" || e.code === "KeyC";
}
export function isPlainCtrlCInterruptChord(e: CopyShortcutKeyEvent): boolean {
return e.ctrlKey
&& !e.shiftKey
&& !e.altKey
&& !e.metaKey
&& isPhysicalCKey(e);
&& (e.key.toLowerCase() === "c" || e.code === "KeyC");
}
/** macOS Cmd+C / Super+C — forward when there is no xterm selection. */
export function isPlainMetaCCopyChord(e: CopyShortcutKeyEvent): boolean {
return e.metaKey
&& !e.ctrlKey
&& !e.shiftKey
&& !e.altKey
&& isPhysicalCKey(e);
}
/**
* When copy matches with no xterm selection, pass the chord through instead of
* consuming an empty clipboard write.
*
* - Ctrl+C → SIGINT (or Kitty Ctrl+C)
* - Cmd+C → Kitty Super+C for nested TUIs (e.g. Herdr)
*
* Other no-selection copy bindings stay consumed as a safe no-op so keys like
* F5 / Ctrl+L are not forwarded to the remote (#1461).
*/
export function shouldPassThroughCopyShortcut(
action: string,
hasSelection: boolean,
e: CopyShortcutKeyEvent,
): boolean {
return action === "copy"
&& !hasSelection
&& (isPlainCtrlCInterruptChord(e) || isPlainMetaCCopyChord(e));
return action === "copy" && !hasSelection && isPlainCtrlCInterruptChord(e);
}
-71
View File
@@ -1,71 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildChatJumpEntries,
chatMessageDomId,
resolveTailCountForJumpTarget,
truncateChatJumpLabel,
} from './chatJumpNav.ts';
test('truncateChatJumpLabel collapses whitespace and ellipsizes', () => {
assert.equal(truncateChatJumpLabel(' hello world '), 'hello world');
assert.equal(
truncateChatJumpLabel('a'.repeat(40), 10),
`${'a'.repeat(7)}...`,
);
assert.equal(truncateChatJumpLabel(' '), '');
});
test('buildChatJumpEntries returns empty until the minimum user-turn threshold', () => {
const messages = [
{ id: 'u1', role: 'user', content: 'one' },
{ id: 'a1', role: 'assistant', content: 'ok' },
{ id: 'u2', role: 'user', content: 'two' },
];
assert.deepEqual(buildChatJumpEntries(messages), []);
assert.equal(
buildChatJumpEntries([
...messages,
{ id: 'u3', role: 'user', content: 'three' },
]).length,
3,
);
});
test('buildChatJumpEntries uses empty label fallback and skips non-user roles', () => {
const entries = buildChatJumpEntries(
[
{ id: 's', role: 'system', content: 'ignore' },
{ id: 'u1', role: 'user', content: ' ' },
{ id: 't', role: 'tool', content: 'tool' },
{ id: 'u2', role: 'user', content: 'second' },
{ id: 'u3', role: 'user', content: 'third' },
],
{ emptyLabel: '(empty)' },
);
assert.deepEqual(entries, [
{ messageId: 'u1', label: '(empty)', index: 1 },
{ messageId: 'u2', label: 'second', index: 2 },
{ messageId: 'u3', label: 'third', index: 3 },
]);
});
test('resolveTailCountForJumpTarget expands only when the target is outside the window', () => {
const visible = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }, { id: 'e' }];
assert.equal(resolveTailCountForJumpTarget(visible, 'd', 2), 2);
assert.equal(resolveTailCountForJumpTarget(visible, 'b', 2), 4);
assert.equal(resolveTailCountForJumpTarget(visible, 'missing', 2), 2);
});
test('resolveTailCountForJumpTarget grows with appends so a pinned target stays mounted', () => {
const before = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }, { id: 'e' }];
const afterJump = resolveTailCountForJumpTarget(before, 'b', 2);
assert.equal(afterJump, 4);
// slice(-4) on a longer list would drop 'b' unless the count is re-resolved.
const afterAppend = [...before, { id: 'f' }, { id: 'g' }];
assert.equal(resolveTailCountForJumpTarget(afterAppend, 'b', afterJump), 6);
});
test('chatMessageDomId prefixes message ids for DOM anchors', () => {
assert.equal(chatMessageDomId('msg-12'), 'ai-chat-msg-msg-12');
});
-72
View File
@@ -1,72 +0,0 @@
/**
* Pure helpers for AI chat in-session jump navigation (user-turn TOC).
*/
export const CHAT_JUMP_MIN_ENTRIES = 3;
export const CHAT_JUMP_LABEL_MAX = 36;
export type ChatJumpMessage = {
id: string;
role: string;
content?: string;
};
export type ChatJumpEntry = {
messageId: string;
label: string;
/** 1-based index among user turns */
index: number;
};
export function chatMessageDomId(messageId: string): string {
return `ai-chat-msg-${messageId}`;
}
export function truncateChatJumpLabel(text: string, maxLen = CHAT_JUMP_LABEL_MAX): string {
const normalized = text.replace(/\s+/g, ' ').trim();
if (!normalized) return '';
if (normalized.length <= maxLen) return normalized;
const ellipsis = '...';
return `${normalized.slice(0, Math.max(1, maxLen - ellipsis.length)).trimEnd()}${ellipsis}`;
}
export function buildChatJumpEntries(
messages: ReadonlyArray<ChatJumpMessage>,
options?: {
minEntries?: number;
maxLabelLen?: number;
emptyLabel?: string;
},
): ChatJumpEntry[] {
const minEntries = options?.minEntries ?? CHAT_JUMP_MIN_ENTRIES;
const maxLabelLen = options?.maxLabelLen ?? CHAT_JUMP_LABEL_MAX;
const emptyLabel = options?.emptyLabel ?? '...';
const entries: ChatJumpEntry[] = [];
for (const message of messages) {
if (message.role !== 'user') continue;
const label = truncateChatJumpLabel(message.content ?? '', maxLabelLen) || emptyLabel;
entries.push({
messageId: message.id,
label,
index: entries.length + 1,
});
}
return entries.length >= minEntries ? entries : [];
}
/**
* Expand the rendered tail window so a jump target is mounted in the DOM.
* Call again whenever the message list grows while the jump target remains
* active; a one-shot expand at jump time is not enough if the tail is
* `slice(-count)` and later appends would otherwise slide the window forward.
*/
export function resolveTailCountForJumpTarget(
visibleMessages: ReadonlyArray<{ id: string }>,
targetMessageId: string,
currentTailCount: number,
): number {
const targetIndex = visibleMessages.findIndex((message) => message.id === targetMessageId);
if (targetIndex < 0) return currentTailCount;
const requiredTail = visibleMessages.length - targetIndex;
return Math.max(currentTailCount, requiredTail);
}
+38 -439
View File
@@ -978,9 +978,6 @@ function takePendingCancel(transferId) {
const admittedActiveByResource = new Map();
let admittedTransferLimit = 2;
const isolatedDownloadChannelPools = new WeakMap();
// Sudo downloads share the browse channel; cap concurrent 64-READ fanout the
// same way isolated channels use FAST_DOWNLOAD_CHANNELS_PER_SESSION (#1507).
const sharedFastDownloadSlots = new WeakMap();
// Cache live SFTP clients where remote cp is known to be unavailable, so we
// skip repeated failed exec attempts without retaining closed session ids.
const cpUnavailableSet = new WeakSet();
@@ -1368,22 +1365,6 @@ function releaseIsolatedDownloadChannel(client, sftp, options = {}) {
scheduleIdleIsolatedDownloadChannel(client, sftp);
}
function tryAcquireSharedFastDownloadSlot(client) {
const inFlight = sharedFastDownloadSlots.get(client) || 0;
if (inFlight >= FAST_DOWNLOAD_CHANNELS_PER_SESSION) return false;
sharedFastDownloadSlots.set(client, inFlight + 1);
return true;
}
function releaseSharedFastDownloadSlot(client) {
const inFlight = sharedFastDownloadSlots.get(client) || 0;
if (inFlight <= 1) {
sharedFastDownloadSlots.delete(client);
return;
}
sharedFastDownloadSlots.set(client, inFlight - 1);
}
async function acquireIsolatedDownloadChannel(client, transfer) {
const pool = getIsolatedDownloadChannelPool(client);
if (transfer?.cancelled) return null;
@@ -1921,171 +1902,12 @@ async function uploadFile(
throw error;
}
/**
* Open a remote SFTP handle while transfer.abort can reject the wait without
* depending on sftp.end(). Isolated channels still pass abortChannel that ends
* the subsystem; shared/sudo channels pass a no-op end path and only reject.
* A late OPEN handle after cancel / channel error is closed best-effort when
* disposeChannel is false so the shared session does not leak handles.
* Channel `error` while OPEN is pending must reject here: callers only check
* recorded channelError after this await returns, so a dead channel that never
* invokes the OPEN callback would otherwise hang the transfer and SFTP lease.
* Shared write opens ("w" / "r+" / …) publish transfer.sharedWriteOpenDrain and
* keep it pending until the OPEN callback finishes (including late opens after
* a cancel settle timeout), so upload cleanup awaits the drain before deleting
* the remote stage. Channel-error / cancel-settle paths that never receive an
* OPEN callback arm a short drain force-complete so a dead channel cannot hold
* the transfer and SFTP lease forever.
*
* @param {{ disposeChannel?: boolean, abortChannel?: (() => void) | null }} [options]
*/
function openSftpHandleForTransfer(sftp, filePath, flags, transfer, options = {}) {
const disposeChannel = options.disposeChannel !== false;
const abortChannel = typeof options.abortChannel === "function"
? options.abortChannel
: null;
// ssh2 string flags: "r" is read-only; anything else can create/truncate.
const isWriteOpen = String(flags ?? "r") !== "r";
const trackSharedWriteDrain = !disposeChannel && isWriteOpen;
let resolveSharedWriteDrain = null;
if (trackSharedWriteDrain) {
transfer.sharedWriteOpenDrain = new Promise((resolve) => {
resolveSharedWriteDrain = resolve;
});
}
function openSftpHandle(sftp, filePath, flags) {
return new Promise((resolve, reject) => {
let settled = false;
let openDrainTimer = null;
let drainForceTimer = null;
const previousAbort = transfer.abort;
const clearDrainForceTimer = () => {
if (!drainForceTimer) return;
clearTimeout(drainForceTimer);
drainForceTimer = null;
};
const completeSharedWriteDrain = () => {
clearDrainForceTimer();
if (!resolveSharedWriteDrain) return;
const resolveDrain = resolveSharedWriteDrain;
resolveSharedWriteDrain = null;
resolveDrain();
};
// When OPEN wait settles without an OPEN callback (channel error, or cancel
// settle timeout), keep drain pending for a late truncating OPEN — but force
// complete if the callback never arrives so cleanup/lease cannot hang.
const armSharedWriteDrainForceComplete = () => {
if (!trackSharedWriteDrain || !resolveSharedWriteDrain || drainForceTimer) return;
drainForceTimer = setTimeout(() => {
drainForceTimer = null;
completeSharedWriteDrain();
}, 2000);
};
const settle = (fn, value) => {
if (settled) return;
settled = true;
if (openDrainTimer) {
clearTimeout(openDrainTimer);
openDrainTimer = null;
}
if (transfer.abort === abortDuringOpen) {
transfer.abort = previousAbort;
}
try { sftp.removeListener?.("error", onOpenChannelError); } catch { /* ignore */ }
fn(value);
};
const abortDuringOpen = () => {
transfer.cancelled = true;
// Shared write OPEN can truncate/create the remote path. A settle timeout
// unblocks the transfer UX, but sharedWriteOpenDrain stays pending until
// the OPEN callback finishes (or the drain force-complete) so cleanup
// cannot race a late truncating OPEN.
if (!disposeChannel && isWriteOpen && !settled) {
try { abortChannel?.(); } catch { /* ignore */ }
if (!openDrainTimer) {
openDrainTimer = setTimeout(() => {
settle(reject, new Error("Transfer cancelled"));
armSharedWriteDrainForceComplete();
}, 2000);
}
return;
}
// Settle before abortChannel so a synchronous OPEN callback from
// sftp.end() cannot win the promise with a channel-close error first.
settle(reject, new Error("Transfer cancelled"));
try { abortChannel?.(); } catch { /* ignore */ }
if (!trackSharedWriteDrain) completeSharedWriteDrain();
};
const onOpenChannelError = (error) => {
settle(reject, error || new Error("SFTP channel error"));
// Shared write: keep drain pending for a late OPEN close, but bound it so
// a dead channel that never invokes the OPEN callback cannot hang forever.
if (!trackSharedWriteDrain) {
completeSharedWriteDrain();
} else {
armSharedWriteDrainForceComplete();
}
};
if (transfer.cancelled) {
completeSharedWriteDrain();
reject(new Error("Transfer cancelled"));
return;
}
transfer.abort = abortDuringOpen;
sftp.on?.("error", onOpenChannelError);
try {
sftp.open(filePath, flags, (error, handle) => {
if (settled) {
// Cancel / channel error already settled (possibly via drain timeout).
// Still close a late shared handle, then release the drain barrier so
// cleanup can run.
if (!error && handle && !disposeChannel) {
closeSftpHandle(sftp, handle).then(
completeSharedWriteDrain,
completeSharedWriteDrain,
);
return;
}
completeSharedWriteDrain();
return;
}
if (error) {
settle(reject, error);
completeSharedWriteDrain();
return;
}
if (transfer.cancelled) {
const finishCancel = () => {
settle(reject, new Error("Transfer cancelled"));
completeSharedWriteDrain();
};
if (handle && !disposeChannel) {
// Close before settle/drain so shared write cleanup runs after the
// truncating OPEN handle is released.
closeSftpHandle(sftp, handle).then(finishCancel, finishCancel);
return;
}
finishCancel();
return;
}
settle(resolve, handle);
completeSharedWriteDrain();
});
} catch (error) {
// Sync throw (destroyed channel / bad state) must still settle and drop
// the error listener; otherwise the shared browse channel leaks listeners.
settle(reject, error);
completeSharedWriteDrain();
}
sftp.open(filePath, flags, (error, handle) => {
if (error) reject(error);
else resolve(handle);
});
});
}
@@ -2160,82 +1982,19 @@ async function verifyFastDownloadSamples(sftp, remoteHandle, localHandle, fileSi
Math.max(0, Math.floor((fileSize - sampleSize) / 2)),
Math.max(0, fileSize - sampleSize),
])];
// Verification READs sit outside runPausableConcurrentRanges. After ranges
// settle, the prior abort hook is a no-op, so shared/sudo cancel (or a
// channel error without a READ callback) would hang forever without a local
// force-settle — same 2s grace as forceSettleOnError for download READs.
let rejectPending = null;
let forceSettleTimer = null;
let verifyDone = false;
let channelError = null;
const previousAbort = transfer.abort;
const cancelError = () => new Error("Transfer cancelled");
const abortDuringVerify = () => {
transfer.cancelled = true;
try { previousAbort?.(); } catch { /* ignore */ }
if (verifyDone || forceSettleTimer) return;
forceSettleTimer = setTimeout(() => {
forceSettleTimer = null;
rejectPending?.(cancelError());
}, 2000);
};
const onVerifyChannelError = (error) => {
// Remember across sample gaps: an error between races (after one sample
// resolves, before the next rejectPending is installed) must still fail
// the next sample instead of letting readSftpRange hang forever.
channelError = channelError || error || new Error("SFTP channel error");
rejectPending?.(channelError);
};
transfer.abort = abortDuringVerify;
sftp.on?.("error", onVerifyChannelError);
try {
if (transfer.cancelled) throw cancelError();
if (channelError) throw channelError;
for (const position of offsets) {
if (transfer.cancelled) throw cancelError();
if (channelError) throw channelError;
const length = Math.min(sampleSize, fileSize - position);
const remoteBuffer = Buffer.allocUnsafe(length);
const localBuffer = Buffer.allocUnsafe(length);
await Promise.race([
(async () => {
await readSftpRange(sftp, remoteHandle, remoteBuffer, position, length);
await readLocalRange(localHandle, localBuffer, position, length);
if (!remoteBuffer.equals(localBuffer)) {
const error = new Error("Transfer source content changed during transfer");
error.noTransferFallback = true;
error.sourceChanged = true;
throw error;
}
})(),
new Promise((_, reject) => {
rejectPending = reject;
if (channelError) {
reject(channelError);
return;
}
if (transfer.cancelled) abortDuringVerify();
}),
]);
// Cancel during a responsive sample can win the race above without the
// force-settle timer firing. Recheck so we never report complete after
// cancel (especially the last sample on direct/non-staged downloads).
if (transfer.cancelled) throw cancelError();
if (channelError) throw channelError;
for (const position of offsets) {
if (transfer.cancelled) throw new Error("Transfer cancelled");
const length = Math.min(sampleSize, fileSize - position);
const remoteBuffer = Buffer.allocUnsafe(length);
const localBuffer = Buffer.allocUnsafe(length);
await readSftpRange(sftp, remoteHandle, remoteBuffer, position, length);
await readLocalRange(localHandle, localBuffer, position, length);
if (!remoteBuffer.equals(localBuffer)) {
const error = new Error("Transfer source content changed during transfer");
error.noTransferFallback = true;
error.sourceChanged = true;
throw error;
}
} finally {
verifyDone = true;
if (forceSettleTimer) {
clearTimeout(forceSettleTimer);
forceSettleTimer = null;
}
rejectPending = null;
if (transfer.abort === abortDuringVerify) {
transfer.abort = previousAbort;
}
try { sftp.removeListener?.("error", onVerifyChannelError); } catch { /* ignore */ }
}
}
@@ -2700,11 +2459,10 @@ async function runPausableConcurrentRanges({
if (settled) return;
if (error) terminalError = terminalError || error;
if (active > 0) {
// forceSettleOnError callers may abandon in-flight ranges after a
// short grace: isolated channels (after sftp.end), and shared
// downloads (READ-only — no remote WRITEs to drain). Shared uploads
// must keep forceSettleOnError false so outstanding WRITEs finish
// before the caller reuses or cleans up the remote path.
// Only an isolated channel may force-settle after aborting the
// subsystem. A shared channel cannot discard outstanding WRITE
// callbacks safely because the caller may clean up or reuse the
// same remote path while those requests are still in flight.
if (terminalError && forceSettleOnError) {
if (!forceFinishTimer) {
forceFinishTimer = setTimeout(() => {
@@ -2764,9 +2522,6 @@ async function runPausableConcurrentRanges({
void copyRange(position, length)
.then(() => {
// After force-settle, abandoned ranges must not publish progress
// or advance checkpoints into the caller's truncate/fallback window.
if (settled) return;
transferred += length;
completedRanges.set(position, position + length);
while (completedRanges.has(contiguousCheckpoint)) {
@@ -2776,12 +2531,8 @@ async function runPausableConcurrentRanges({
}
publishContiguousCheckpoint(false);
})
.catch((error) => {
if (settled) return;
abort(error);
})
.catch((error) => abort(error))
.finally(() => {
if (settled) return;
active -= 1;
settlePauseWaiters();
if (terminalError || transfer.cancelled) {
@@ -2868,9 +2619,8 @@ async function uploadFileConcurrent(
channelError = channelError || error;
};
sftp.on?.("error", onChannelError);
// Install cancel before OPEN so a stalled remote open can still settle:
// isolated channels end the subsystem; shared/sudo reject OPEN without end().
// (runPausableConcurrentRanges would install abort later, after OPEN.)
// Install cancel before OPEN so a stalled remote open can still end an
// isolated channel (runPausableConcurrentRanges would install this later).
const abortChannel = () => {
if (disposeChannel) {
try { sftp.end?.(); } catch { /* ignore */ }
@@ -2926,13 +2676,7 @@ async function uploadFileConcurrent(
digestHandle = await fs.promises.open(ephemeralDigestPath, "r");
}
if (transfer.cancelled) throw new Error("Transfer cancelled");
remoteHandle = await openSftpHandleForTransfer(
sftp,
remotePath,
checkpoint > 0 ? "r+" : "w",
transfer,
{ disposeChannel, abortChannel },
);
remoteHandle = await openSftpHandle(sftp, remotePath, checkpoint > 0 ? "r+" : "w");
if (channelError) throw channelError;
if (transfer.cancelled) throw new Error("Transfer cancelled");
@@ -2990,19 +2734,6 @@ async function uploadFileConcurrent(
}
throw error;
} finally {
// Shared write OPEN may still be in flight after a cancel settle timeout or
// channel error. Await the drain barrier before returning so
// runRemoteUploadTransaction cannot clean up the stage before a late
// truncating OPEN finishes (Codex P2 on 747847e7). openSftpHandleForTransfer
// force-completes the drain if the OPEN callback never arrives on a dead
// channel, so this await cannot hang forever.
const sharedWriteOpenDrain = transfer.sharedWriteOpenDrain;
if (sharedWriteOpenDrain) {
await sharedWriteOpenDrain.catch(() => {});
if (transfer.sharedWriteOpenDrain === sharedWriteOpenDrain) {
transfer.sharedWriteOpenDrain = null;
}
}
transfer.readStream = null;
transfer.waitForPause = null;
transfer.cancelPauseWait = null;
@@ -3057,11 +2788,6 @@ async function uploadFileConcurrent(
* durable byte; out-of-order range completion cannot advance past a hole.
* Once pause is requested, no new ranges are scheduled and we wait for every
* in-flight range before acknowledging it.
*
* @param {{ disposeChannel?: boolean }} [options]
* disposeChannel — when true (isolated channel), cancel/abort may call
* sftp.end(); the caller still returns healthy channels to the download pool.
* When false (shared browse / sudo session), never call sftp.end().
*/
async function downloadFileResumableFast(
remotePath,
@@ -3070,26 +2796,18 @@ async function downloadFileResumableFast(
fileSize,
transfer,
sendProgress,
options = {},
) {
const disposeChannel = options.disposeChannel !== false;
const checkpoint = Math.max(0, Math.min(transfer.checkpointBytes || 0, fileSize));
let channelError = null;
const onChannelError = (error) => {
channelError = channelError || error;
};
sftp.on?.("error", onChannelError);
// Install cancel before OPEN so a stalled remote open can still settle:
// isolated channels end the subsystem; shared/sudo reject OPEN without end().
// (runPausableConcurrentRanges would install abort later, after OPEN.)
const abortChannel = () => {
if (disposeChannel) {
try { sftp.end?.(); } catch { /* ignore */ }
}
};
// Install cancel before OPEN so a stalled remote open can still end the
// isolated channel (runPausableConcurrentRanges would install this later).
const abortEarly = () => {
transfer.cancelled = true;
abortChannel();
try { sftp.end?.(); } catch { }
};
transfer.abort = abortEarly;
@@ -3098,13 +2816,7 @@ async function downloadFileResumableFast(
let failed = false;
try {
if (transfer.cancelled) throw new Error("Transfer cancelled");
remoteHandle = await openSftpHandleForTransfer(
sftp,
remotePath,
"r",
transfer,
{ disposeChannel, abortChannel },
);
remoteHandle = await openSftpHandle(sftp, remotePath, "r");
if (channelError) throw channelError;
if (transfer.cancelled) throw new Error("Transfer cancelled");
localHandle = await fs.promises.open(localPath, checkpoint > 0 ? "r+" : "w+");
@@ -3122,11 +2834,8 @@ async function downloadFileResumableFast(
await writeLocalRange(localHandle, buffer, position, length);
},
sendProgress,
abortChannel,
abortChannel: () => sftp.end?.(),
sftp,
// Always force-settle on cancel/error: downloads have no remote WRITEs
// to drain. Shared/sudo paths cannot sftp.end() a stuck READ, so without
// this a hung read callback would hold the transfer and SFTP lease forever.
forceSettleOnError: true,
});
if (channelError) throw channelError;
@@ -3152,52 +2861,11 @@ async function downloadFileResumableFast(
}
}
let remoteCloseError = null;
// Close remote handles while the channel is still live. On disposeChannel
// cancel/failure the channel is about to be ended (or already dead); a
// CLOSE request would hang forever with no callback from ssh2.
if (remoteHandle) {
const skipClose = disposeChannel && (failed || transfer.cancelled);
if (!skipClose) {
if (failed || transfer.cancelled) {
// Shared cancel/failure already settled the transfer; do not await
// CLOSE (adds up to 2s on a dead channel). Best-effort close only.
closeSftpHandle(sftp, remoteHandle).catch(() => {});
} else {
// Success path: bound CLOSE so a hung callback cannot block lease
// release. Tag the watchdog so real server "timed out" CLOSE errors
// are not swallowed by message matching.
let closeTimeout = null;
try {
await Promise.race([
closeSftpHandle(sftp, remoteHandle),
new Promise((_, reject) => {
closeTimeout = setTimeout(() => {
const timeoutError = new Error("SFTP close timed out");
timeoutError.sftpCloseTimedOut = true;
reject(timeoutError);
}, 2000);
}),
]);
} catch (error) {
if (error?.sftpCloseTimedOut) {
console.warn(
"[transferBridge] SFTP CLOSE timed out; abandoning remote handle",
transfer.transferId || "",
disposeChannel ? "(isolated — dispose channel)" : "(shared — keep session)",
);
// Shared/sudo: timeout is non-fatal (session stays alive).
// Isolated: mark unhealthy so downloadFile disposes instead of
// returning a possibly-dead channel to the pool.
if (disposeChannel) {
remoteCloseError = error;
}
} else {
remoteCloseError = error;
}
} finally {
if (closeTimeout) clearTimeout(closeTimeout);
}
}
if (remoteHandle && !failed && !transfer.cancelled) {
try {
await closeSftpHandle(sftp, remoteHandle);
} catch (error) {
remoteCloseError = error;
}
}
if (!failed && !transfer.cancelled && !remoteCloseError && channelError) {
@@ -3210,12 +2878,9 @@ async function downloadFileResumableFast(
throw error;
}
if (!failed && !transfer.cancelled && remoteCloseError) {
if (disposeChannel) {
const error = new Error("The isolated SFTP channel failed while closing", { cause: remoteCloseError });
error.completedWithUnhealthyChannel = true;
throw error;
}
throw remoteCloseError;
const error = new Error("The isolated SFTP channel failed while closing", { cause: remoteCloseError });
error.completedWithUnhealthyChannel = true;
throw error;
}
}
}
@@ -3367,73 +3032,7 @@ async function downloadFile(
}
}
// Sudo cannot open an isolated secondary channel, so elevated downloads must
// pipeline READs on the shared browse session (#2719). Cap concurrent shared
// fanout with the same per-session budget as isolated fast downloads (#1507).
// Non-sudo isolated misses stay on serial createReadStream below.
if (
client.__netcattySudoMode
&& typeof sftp.open === "function"
&& typeof sftp.read === "function"
&& tryAcquireSharedFastDownloadSlot(client)
) {
try {
try {
transfer.downloadStrategy = "concurrent-shared";
logTransferDiag(transfer, "strategy", {
strategy: "concurrent-shared",
fields: {
chunk: formatDiagBytes(TRANSFER_CHUNK_SIZE),
concurrency: DOWNLOAD_TRANSFER_CONCURRENCY,
},
});
await downloadFileResumableFast(
remotePath,
localPath,
sftp,
fileSize,
transfer,
sendProgress,
{ disposeChannel: false },
);
if (initialSource) {
const latestSource = await runCancelablePreflight(() => client.stat(remotePath));
await assertDownloadSourceAfterTransfer(initialSource, latestSource, fileSize, {
localPath,
client,
remotePath,
signal: transfer.signal,
});
}
return;
} catch (err) {
if (transfer.cancelled) throw err;
if (err?.noTransferFallback) throw err;
// Concurrent ranges may leave sparse tails past the contiguous
// checkpoint; truncate the actual local target before stream resume.
const sharedCheckpoint = Math.max(0, Math.min(transfer.checkpointBytes || 0, fileSize));
try {
await fs.promises.truncate(localPath, sharedCheckpoint);
} catch (truncateError) {
if (!(sharedCheckpoint === 0 && truncateError?.code === "ENOENT")) {
throw truncateError;
}
}
sendProgress(sharedCheckpoint, fileSize, { force: true, checkpointBytes: sharedCheckpoint });
// Clear so the stream fallback below reports the strategy that actually ran.
transfer.downloadStrategy = null;
console.warn(
"[transferBridge] concurrent shared download failed, falling back to a compatible stream:",
err?.message || String(err),
);
}
} finally {
releaseSharedFastDownloadSlot(client);
}
}
// Fallback: sequential stream piping
transfer.downloadStrategy = transfer.downloadStrategy || "stream";
const checkpoint = Math.max(0, Math.min(transfer.checkpointBytes || 0, fileSize));
if (checkpoint >= fileSize) {
// Planned snapshot is already fully staged (including zero-byte snapshots).
File diff suppressed because it is too large. Load diff
+36 -84
View File
@@ -241,111 +241,64 @@ function compareTapResults(baseline, candidate) {
missingBaselineFailures.push(failure.name);
}
const result = ({
passed,
kind,
newFailures = [],
candidateFailures = candidate.exitCode === 0 ? [] : candidate.failures,
}) => ({
passed,
kind,
baselineFailures: baseline.failures,
candidateFailures,
newFailures,
missingBaselineSuccesses,
missingBaselineFailures,
});
if (candidate.exitCode === 0) {
// Require every baseline failure title to reappear as a same-named success
// before accepting a red-to-green transition. Aggregate counts alone would allow
// deleting the failing test and adding an unrelated passer.
const baselineFailCounts = countFailures(baseline.failures);
const baselineSuccessCountsForFix = countFailures(baseline.successes);
const candidateSuccessCountsForFix = countFailures(candidate.successes);
const unresolvedBaselineFailures = [];
for (const [name, failCount] of baselineFailCounts) {
const required =
failCount + (baselineSuccessCountsForFix.get(name) || 0);
const available = candidateSuccessCountsForFix.get(name) || 0;
for (let i = 0; i < required - available; i += 1) {
unresolvedBaselineFailures.push(name);
}
}
// A fully green candidate may rename success titles when quantitative
// coverage does not shrink, but only against a complete baseline summary.
// An incomplete baseline cannot prove coverage was preserved, so fail closed.
const quantitativeClean =
baseline.complete &&
candidate.complete &&
candidate.failCount === 0 &&
candidate.cancelledCount === 0 &&
unresolvedBaselineFailures.length === 0 &&
candidate.skippedCount <= baseline.skippedCount &&
candidate.todoCount <= baseline.todoCount &&
candidate.testCount >= baseline.testCount;
if (quantitativeClean) {
return result({ passed: true, kind: 'clean' });
}
const coverageShrunk =
const completeCleanRun =
baseline.complete &&
candidate.complete &&
candidate.failCount === 0 &&
candidate.cancelledCount === 0 &&
candidate.skippedCount <= baseline.skippedCount &&
candidate.todoCount <= baseline.todoCount &&
candidate.testCount < baseline.testCount &&
missingBaselineSuccesses.length > 0;
if (coverageShrunk) {
return result({
passed: false,
kind: 'missing_baseline_successes',
newFailures: missingBaselineSuccesses,
});
}
return result({
passed: false,
kind: 'unclassified_failure',
newFailures: unresolvedBaselineFailures.length
? unresolvedBaselineFailures
: missingBaselineSuccesses,
});
candidate.testCount >= baseline.testCount &&
missingBaselineSuccesses.length === 0 &&
missingBaselineFailures.length === 0;
return {
passed: completeCleanRun,
kind: completeCleanRun ? 'clean' : 'unclassified_failure',
baselineFailures: baseline.failures,
candidateFailures: [],
newFailures: [],
};
}
if (baseline.exitCode === 0) {
return result({
return {
passed: false,
kind: 'new_failures',
baselineFailures: [],
candidateFailures: candidate.failures,
newFailures: candidate.failures,
});
};
}
if (!baseline.complete || !candidate.complete) {
return result({
return {
passed: false,
kind: 'unclassified_failure',
baselineFailures: baseline.failures,
candidateFailures: candidate.failures,
newFailures: candidate.failures,
});
};
}
if (missingBaselineSuccesses.length) {
return result({
return {
passed: false,
kind: 'missing_baseline_successes',
kind: 'unclassified_failure',
baselineFailures: baseline.failures,
candidateFailures: candidate.failures,
newFailures: missingBaselineSuccesses,
});
};
}
if (candidate.exitCode !== baseline.exitCode) {
return result({
return {
passed: false,
kind: 'unclassified_failure',
baselineFailures: baseline.failures,
candidateFailures: candidate.failures,
newFailures: candidate.failures,
});
};
}
const baselineNonTapCounts = countFailures(baseline.nonTapOutput);
@@ -354,21 +307,23 @@ function compareTapResults(baseline, candidate) {
([line, count]) => count > (baselineNonTapCounts.get(line) || 0),
);
if (addedNonTapOutput) {
return result({
return {
passed: false,
kind: 'unclassified_failure',
baselineFailures: baseline.failures,
candidateFailures: candidate.failures,
newFailures: candidate.failures,
});
};
}
if (candidate.failCount === 0 || candidate.testCount < baseline.testCount) {
return result({
return {
passed: false,
kind: 'unclassified_failure',
baselineFailures: baseline.failures,
candidateFailures: candidate.failures,
newFailures: candidate.failures,
});
};
}
if (
@@ -376,12 +331,13 @@ function compareTapResults(baseline, candidate) {
candidate.skippedCount > baseline.skippedCount ||
candidate.todoCount > baseline.todoCount
) {
return result({
return {
passed: false,
kind: 'cancelled_tests',
baselineFailures: baseline.failures,
candidateFailures: candidate.failures,
newFailures: candidate.failures,
});
};
}
const baselineCounts = countFailures(
@@ -408,16 +364,17 @@ function compareTapResults(baseline, candidate) {
const reportedFailures = newFailures.length
? newFailures
: missingBaselineFailures;
return result({
return {
passed,
kind: passed
? 'baseline_only'
: newFailures.length
? 'new_failures'
: 'unclassified_failure',
baselineFailures: baseline.failures,
candidateFailures: candidate.failures,
newFailures: reportedFailures,
});
};
}
function parseArgs(argv) {
@@ -461,11 +418,6 @@ function main(argv = process.argv.slice(2)) {
if (result.newFailures.length) {
console.error(`New failures:\n- ${result.newFailures.join('\n- ')}`);
}
if (!result.passed && result.missingBaselineSuccesses?.length) {
console.error(
`Missing baseline successes:\n- ${result.missingBaselineSuccesses.join('\n- ')}`,
);
}
return result.passed ? 0 : 1;
}
+9 -49
View File
@@ -36,14 +36,13 @@ test('rejects a zero-exit candidate without a complete clean TAP summary', () =>
assert.equal(result.kind, 'unclassified_failure');
});
test('accepts renaming a successful test when quantitative coverage does not shrink', () => {
test('rejects replacing an existing successful test with an unrelated one', () => {
const result = compareTapResults(
parseTapResult(tap({ successes: ['kept test', 'removed test'] }), 0),
parseTapResult(tap({ successes: ['kept test', 'unrelated replacement'] }), 0),
);
assert.equal(result.passed, true);
assert.equal(result.kind, 'clean');
assert.deepEqual(result.missingBaselineSuccesses, ['removed test']);
assert.equal(result.passed, false);
assert.equal(result.kind, 'unclassified_failure');
const issueNumberName = compareTapResults(
parseTapResult(tap({
@@ -53,55 +52,16 @@ test('accepts renaming a successful test when quantitative coverage does not shr
successes: ['accepts packages that ship upstream #9999'],
}), 0),
);
assert.equal(issueNumberName.passed, true);
assert.equal(issueNumberName.kind, 'clean');
assert.equal(issueNumberName.passed, false);
// Clean candidate that fixes a prior red must keep the failing test title.
const fixedFailure = compareTapResults(
parseTapResult(tap({ failures: ['broken test'], successes: ['other test'] }), 1),
parseTapResult(tap({ successes: ['broken test', 'other test'] }), 0),
const duplicateName = compareTapResults(
parseTapResult(tap({ failures: ['same name'], successes: ['same name'] }), 1),
parseTapResult(tap({ successes: ['same name', 'unrelated replacement'] }), 0),
);
assert.equal(fixedFailure.passed, true);
assert.equal(fixedFailure.kind, 'clean');
// Deleting the failing test and adding an unrelated passer is not a clean fix.
const deletedFailure = compareTapResults(
parseTapResult(tap({ failures: ['broken test'], successes: ['other test'] }), 1),
parseTapResult(tap({ successes: ['other test', 'unrelated replacement'] }), 0),
);
assert.equal(deletedFailure.passed, false);
assert.equal(deletedFailure.kind, 'unclassified_failure');
assert.deepEqual(deletedFailure.missingBaselineFailures, ['broken test']);
assert.equal(duplicateName.passed, false);
});
test('accepts a green candidate that renames one success while adding another', () => {
const result = compareTapResults(
parseTapResult(tap({
successes: ['alpha', 'beta', 'gamma'],
tests: 8886,
}), 0),
parseTapResult(tap({
successes: ['alpha', 'beta', 'delta', 'epsilon'],
tests: 8887,
}), 0),
);
assert.equal(result.passed, true);
assert.equal(result.kind, 'clean');
assert.deepEqual(result.missingBaselineSuccesses, ['gamma']);
});
test('rejects deleting successful coverage even when the run stays green', () => {
const result = compareTapResults(
parseTapResult(tap({ successes: ['kept test', 'removed test'], tests: 12 }), 0),
parseTapResult(tap({ successes: ['kept test'], tests: 11 }), 0),
);
assert.equal(result.passed, false);
assert.equal(result.kind, 'missing_baseline_successes');
assert.deepEqual(result.missingBaselineSuccesses, ['removed test']);
assert.deepEqual(result.newFailures, ['removed test']);
});
test('rejects a clean candidate when the exact-base TAP summary is incomplete (fail closed)', () => {
test('rejects a clean candidate when the exact-base TAP summary is incomplete', () => {
const result = compareTapResults(
parseTapResult('base runner stopped early', 1),
parseTapResult(tap(), 0),
+26 -279
View File
@@ -1805,80 +1805,18 @@ function buildExternalCodexRerequestComment(headSha) {
].join('\n');
}
/**
* True when a comment body is a Codex review *request* (not a clean summary).
* Matches "@codex review" as a mention token, not "Codex Review: …" summaries.
*/
function isCodexReviewRequestBody(body) {
const text = String(body || '');
if (!/(^|[^A-Za-z0-9_@])@codex\s+review\b/i.test(text)) return false;
// Connector clean/noise posts never use the @codex request form.
return true;
}
/**
* Authors whose @codex review posts count for same-head dedupe.
* Includes maintainers/GITHUB_TOKEN and Cursor's PR bot (often posts a plain
* @codex review before our own_rerequest job lands).
*/
function isCodexRequestDedupeAuthor(login, { ownActors } = {}) {
if (isTrustedAutomationControlAuthor(login, { ownActors })) return true;
const name = String(login || '')
.trim()
.toLowerCase()
.replace(/^@/, '');
return name === 'cursor[bot]' || name === 'cursor';
}
/**
* Skip posting another @codex review when this head was already requested.
*
* Historical bug: only `cursor-external-codex:SHA` counted. Automation
* comments that pin `cursor-codex-head:SHA` (and maintainer requests built the
* same way) did not, so a second synchronize-path request could fire for the
* same SHA while an earlier job was still in flight — clean issue comment from
* one job, findings review from the other.
*
* Skip when a trusted/dedupe author already requested this head via:
* - `cursor-external-codex:SHA`, or
* - `cursor-codex-head:SHA` pin matching headSha.
*
* Plain unpinned "@codex review" is intentionally ignored: without a head pin
* we cannot tell which SHA it targeted, and timestamp heuristics (age, commit
* date, PR.updated_at) all have false-positive races. Prefer always planting
* a head pin (buildCodexReviewRequestComment) so re-request is safe.
*
* `notBefore` is accepted but unused (kept so older workflow callers do not
* throw if they still pass it).
*/
function shouldSkipExternalCodexRerequest({
existingComments = [],
headSha,
ownActors,
notBefore: _notBefore = null,
notBeforeSlackMs: _notBeforeSlackMs = 5_000,
} = {}) {
const want = String(headSha || '')
.trim()
.toLowerCase();
if (!want) return false;
const externalMarker = `<!-- cursor-external-codex:${sanitizeUntrustedText(want, 64)} -->`;
return existingComments.some((c) => {
const login = c?.user?.login || c?.login;
if (!isCodexRequestDedupeAuthor(login, { ownActors })) return false;
const body = String(c?.body || '');
if (!isCodexReviewRequestBody(body)) return false;
// Explicit external marker for this SHA (legacy + current automation).
if (body.includes(externalMarker)) return true;
// Automation / human request that pins this head.
const pinned = extractRequestedHeadSha(body);
if (pinned && commitShasMatch(want, pinned)) return true;
return false;
});
const marker = `<!-- cursor-external-codex:${sanitizeUntrustedText(headSha, 64)} -->`;
return existingComments.some(
(c) =>
isTrustedAutomationControlAuthor(c?.user?.login || c?.login, {
ownActors,
}) && String(c.body || '').includes(marker),
);
}
function buildSlackPayload({
@@ -2315,121 +2253,6 @@ const ISSUE_FOLLOWUP_LABELS = new Set([
'triage:unclear',
]);
/** Outcome labels that mean automatic triage already finished once. */
const ISSUE_ADMITTED_LABELS = new Set([
'triage:admitted',
'needs-info',
'ready-for-agent',
'ready-for-human',
'triage:bug-ready',
'triage:bug-needs-info',
'triage:feature-quick-win',
'triage:feature-defer',
'triage:already-available',
'triage:other',
'triage:unclear',
'unclear',
]);
/** Auto-closed triage outcomes that a human reopen should hand to maintainers. */
const ISSUE_AUTO_CLOSE_HANDOFF_LABELS = new Set([
'triage:already-available',
'triage:unclear',
'unclear',
]);
const REOPEN_HANDOFF_MARKER = '<!-- cursor-reopen-handoff -->';
function normalizeIssueLabelNames(labels = []) {
return (labels || [])
.map((label) => (typeof label === 'string' ? label : label?.name))
.filter(Boolean);
}
function isIssueAlreadyAdmitted(labels = []) {
return normalizeIssueLabelNames(labels).some((name) =>
ISSUE_ADMITTED_LABELS.has(name),
);
}
function labelsForReadyForHumanHandoff(existingLabels = []) {
const existing = normalizeIssueLabelNames(existingLabels);
// Keep triage:already-available / unclear as dispute signals so later author
// follow-ups stay on issue_followup instead of re-entering classify.
const drop = new Set(['ready-for-agent']);
const preserved = existing.includes('triage:admitted')
? ['triage:admitted']
: [];
return [
...new Set([
...existing.filter((name) => !drop.has(name) && name !== 'ready-for-human'),
'triage',
'ready-for-human',
...preserved,
]),
];
}
/**
* Route issues opened/reopened without always re-running agent classify.
* Reopen of an already-triaged issue must not replay the close/reopen loop.
*/
function decideIssuesEventRoute({
action,
labels = [],
actorLogin,
botLogins = ['netcatty-bot', 'github-actions[bot]'],
} = {}) {
const normalizedAction = String(action || '').toLowerCase();
if (normalizedAction === 'opened') {
return { kind: 'issue_classify', reason: 'issues:opened' };
}
if (normalizedAction !== 'reopened') {
return {
kind: 'skip',
reason: `issues:${normalizedAction || 'unknown'}`,
};
}
const names = normalizeIssueLabelNames(labels);
const bots = normalizeLoginList(botLogins, [
'netcatty-bot',
'github-actions[bot]',
]);
const actor = String(actorLogin || '').trim().toLowerCase();
if (actor && bots.has(actor)) {
return {
kind: 'skip',
reason: 'bot reopen of managed issue',
};
}
if (!isIssueAlreadyAdmitted(names)) {
return { kind: 'issue_classify', reason: 'issues:reopened' };
}
if (names.some((name) => ISSUE_AUTO_CLOSE_HANDOFF_LABELS.has(name))) {
return {
kind: 'ready_for_human_handoff',
reason: 'human reopen of auto-closed triage',
};
}
// Merged-fix / bug-ready / feature reopens must re-enter classify so a
// failed fix can spawn another implementation. Bot reopen is already skipped
// above, which covers follow-up reopen side effects.
return {
kind: 'issue_classify',
reason: 'issues:reopened admitted non-auto-close',
};
}
function buildReopenHandoffReply(issue = {}) {
const chinese = /[\u3400-\u9fff]/u.test(
`${issue.title || ''}\n${issue.body || ''}`,
);
return chinese
? '这条 Issue 已重新打开,并交给维护者继续处理。自动分流不会再次把它标成“已有能力/可关闭”。'
: 'This issue was reopened and handed to a maintainer. Automatic triage will not close it again as already available.';
}
function normalizeLoginList(value, fallback = []) {
const values = Array.isArray(value) ? value : String(value || '').split(',');
const normalized = values
@@ -2485,23 +2308,21 @@ function decideIssueCommentRoute({
}
const isManaged = names.some((name) => ISSUE_FOLLOWUP_LABELS.has(name));
const trustedAssociation = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(
String(commenterAssociation || '').toUpperCase(),
);
// Prefer maintainer @bot over plain author follow-up so maintainers who also
// filed the issue can still force re-triage (incl. auto-closed disputes).
if (isManaged && trustedAssociation && mentionsIssueBot(body, botLogins)) {
return {
kind: 'issue_followup',
reason: 'maintainer mentioned issue bot',
};
}
if (isAuthor && isManaged) {
return {
kind: 'issue_followup',
reason: 'author follow-up on managed issue',
};
}
const trustedAssociation = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(
String(commenterAssociation || '').toUpperCase(),
);
if (isManaged && trustedAssociation && mentionsIssueBot(body, botLogins)) {
return {
kind: 'issue_followup',
reason: 'maintainer mentioned issue bot',
};
}
return { kind: 'skip', reason: 'issue comment no follow-up signal' };
}
@@ -2510,31 +2331,15 @@ function refineIssueCommentRoute(decision, {
hasOpenBotPull = false,
hasOpenRelatedPull = false,
body = '',
labels = [],
} = {}) {
if (decision?.kind !== 'issue_followup' || hasOpenBotPull) return decision;
const names = normalizeIssueLabelNames(labels);
// Disputed auto-close outcomes (already_available / unclear) must stay on
// follow-up after author reopen handoff so classify cannot auto-close again.
// Maintainer @bot mentions are explicit re-triage requests and must still
// re-enter classify even when those dispute labels remain.
// Do not gate on ready-for-human alone — that label also covers
// feature_defer / other / implement failures that should reclassify.
if (
names.some((name) => ISSUE_AUTO_CLOSE_HANDOFF_LABELS.has(name))
&& decision.reason !== 'maintainer mentioned issue bot'
) {
return decision;
}
const simpleKind = classifySimpleIssueFollowup([{ body }]);
if (simpleKind) return decision;
return {
kind: 'issue_classify',
reason: hasOpenRelatedPull
? 'actionable author follow-up with trusted related PR'
: decision.reason === 'maintainer mentioned issue bot'
? 'actionable maintainer bot mention without open automation PR'
: 'actionable author follow-up without open automation PR',
: 'actionable author follow-up without open automation PR',
};
}
@@ -3205,8 +3010,6 @@ async function markNeedsHuman({
message,
dedupeMarker = '',
trustedCommentAuthors = 'binaricat,netcatty-bot,github-actions[bot]',
labels,
ensureOpen = false,
}) {
const owner = context.repo.owner;
const repo = context.repo.repo;
@@ -3216,19 +3019,19 @@ async function markNeedsHuman({
issue_number: Number(issueNumber),
});
const existing = issue.labels.map((l) => (typeof l === 'string' ? l : l.name));
const next = Array.isArray(labels)
? [...new Set(labels.filter(Boolean))]
: labelsForReadyForHumanHandoff(existing);
const update = {
const next = [
...new Set([
...existing.filter((l) => l !== 'ready-for-agent'),
'triage',
'ready-for-human',
]),
];
await github.rest.issues.update({
owner,
repo,
issue_number: issue.number,
labels: next,
};
if (ensureOpen && String(issue.state || '').toLowerCase() === 'closed') {
update.state = 'open';
}
await github.rest.issues.update(update);
});
const marker = String(dedupeMarker || '').trim();
if (marker) {
const trusted = normalizeLoginList(trustedCommentAuthors, [
@@ -3262,53 +3065,6 @@ async function markNeedsHuman({
return { issue, labels: next, commented: true };
}
async function applyReadyForHumanHandoff({
github,
context,
issueNumber,
message,
dedupeMarker = REOPEN_HANDOFF_MARKER,
trustedCommentAuthors = 'binaricat,netcatty-bot,github-actions[bot]',
} = {}) {
const { data: issue } = await github.rest.issues.get({
...context.repo,
issue_number: Number(issueNumber),
});
const names = normalizeIssueLabelNames(issue.labels);
// Reopen already left the issue open. If a maintainer re-closed it while this
// handoff was queued, do not force it open again.
if (String(issue.state || '').toLowerCase() === 'closed') {
return {
issue,
labels: names,
commented: false,
skipped: true,
reason: 'issue already closed',
};
}
// Revalidate at apply time: a queued handoff must not undo a maintainer who
// already cleared the auto-close outcome and moved the issue elsewhere.
if (!names.some((name) => ISSUE_AUTO_CLOSE_HANDOFF_LABELS.has(name))) {
return {
issue,
labels: names,
commented: false,
skipped: true,
reason: 'auto-close handoff labels no longer present',
};
}
return markNeedsHuman({
github,
context,
issueNumber,
message: message || buildReopenHandoffReply(issue),
dedupeMarker,
trustedCommentAuthors,
labels: labelsForReadyForHumanHandoff(issue.labels),
ensureOpen: false,
});
}
function isBotPrForIssue(pull, issueNumber) {
if (!pull) return false;
const n = String(issueNumber);
@@ -3953,18 +3709,9 @@ module.exports = {
CODEX_REQUEST_RETRY_MS,
decideCodexLoopAction,
shouldReTriageIssueComment,
ISSUE_ADMITTED_LABELS,
ISSUE_AUTO_CLOSE_HANDOFF_LABELS,
REOPEN_HANDOFF_MARKER,
normalizeIssueLabelNames,
isIssueAlreadyAdmitted,
labelsForReadyForHumanHandoff,
decideIssuesEventRoute,
buildReopenHandoffReply,
mentionsIssueBot,
decideIssueCommentRoute,
refineIssueCommentRoute,
applyReadyForHumanHandoff,
formatCodexFindingsMarkdown,
listProtectedPathHits,
formatProtectedPathDetails,
+2 -438
View File
@@ -627,119 +627,6 @@ test('actionable author follow-ups without an open bot PR are reclassified', ()
}),
decision,
);
assert.equal(
auto.refineIssueCommentRoute(decision, {
hasOpenBotPull: false,
labels: ['triage:already-available', 'triage:admitted'],
body: '我从 main build 了,还是一样,本地网络权限没有弹窗。',
}),
decision,
);
assert.equal(
auto.refineIssueCommentRoute(decision, {
hasOpenBotPull: false,
labels: ['triage:unclear', 'unclear', 'ready-for-human'],
body: '补充:复现步骤是打开 Vault 再连局域网主机。',
}),
decision,
);
// ready-for-human alone must NOT block reclassify (feature_defer / other /
// implement-failure handoffs still need actionable follow-ups to classify).
assert.deepEqual(
auto.refineIssueCommentRoute(decision, {
hasOpenBotPull: false,
labels: ['triage', 'triage:feature-defer', 'ready-for-human'],
body: '默认开启 X11 就可以,内置服务暂时不需要。',
}),
{
kind: 'issue_classify',
reason: 'actionable author follow-up without open automation PR',
},
);
// After reopen handoff, triage:already-available is preserved with
// ready-for-human so disputes cannot re-close via classify.
assert.equal(
auto.refineIssueCommentRoute(decision, {
hasOpenBotPull: false,
labels: [
'triage',
'triage:admitted',
'triage:already-available',
'ready-for-human',
],
body: '我从 main build 了,还是一样,本地网络权限没有弹窗。',
}),
decision,
);
});
test('decideIssuesEventRoute skips bot reopen and hands auto-closed reopen to humans', () => {
assert.deepEqual(
auto.decideIssuesEventRoute({ action: 'opened', labels: [] }),
{ kind: 'issue_classify', reason: 'issues:opened' },
);
assert.deepEqual(
auto.decideIssuesEventRoute({
action: 'reopened',
labels: ['bug', 'triage'],
actorLogin: 'alice',
}),
{ kind: 'issue_classify', reason: 'issues:reopened' },
);
assert.deepEqual(
auto.decideIssuesEventRoute({
action: 'reopened',
labels: ['triage:admitted', 'triage:already-available'],
actorLogin: 'netcatty-bot',
}),
{ kind: 'skip', reason: 'bot reopen of managed issue' },
);
assert.deepEqual(
auto.decideIssuesEventRoute({
action: 'reopened',
labels: ['triage:admitted', 'triage:already-available'],
actorLogin: 'binaricat',
}),
{
kind: 'ready_for_human_handoff',
reason: 'human reopen of auto-closed triage',
},
);
assert.deepEqual(
auto.decideIssuesEventRoute({
action: 'reopened',
labels: ['triage:admitted', 'ready-for-human', 'triage'],
actorLogin: 'binaricat',
}),
{
kind: 'issue_classify',
reason: 'issues:reopened admitted non-auto-close',
},
);
assert.deepEqual(
auto.decideIssuesEventRoute({
action: 'reopened',
labels: ['triage:admitted', 'triage:bug-ready', 'bug'],
actorLogin: 'alice',
}),
{
kind: 'issue_classify',
reason: 'issues:reopened admitted non-auto-close',
},
);
const handoff = auto.labelsForReadyForHumanHandoff([
'bug',
'triage',
'triage:admitted',
'triage:already-available',
'ready-for-agent',
]);
assert.ok(handoff.includes('ready-for-human'));
assert.ok(handoff.includes('triage:admitted'));
assert.ok(handoff.includes('triage:already-available'));
assert.ok(!handoff.includes('ready-for-agent'));
assert.equal(auto.isIssueAlreadyAdmitted(['triage:admitted']), true);
assert.equal(auto.isIssueAlreadyAdmitted(['bug', 'triage']), false);
});
test('decideIssueCommentRoute accepts maintainer @bot and ignores untrusted bystanders', () => {
@@ -766,81 +653,6 @@ test('decideIssueCommentRoute accepts maintainer @bot and ignores untrusted byst
assert.equal(auto.mentionsIssueBot('补充:@netcatty-bot请再确认'), true);
});
test('maintainer @bot on auto-closed labels can reclassify without open bot PR', () => {
const maintainerDecision = auto.decideIssueCommentRoute({
labels: ['triage:already-available', 'triage:admitted', 'ready-for-human'],
commenterLogin: 'maintainer',
issueAuthorLogin: 'alice',
commenterAssociation: 'MEMBER',
body: '@netcatty-bot 请重新分流,这个能力其实还没有。',
});
assert.deepEqual(maintainerDecision, {
kind: 'issue_followup',
reason: 'maintainer mentioned issue bot',
});
assert.deepEqual(
auto.refineIssueCommentRoute(maintainerDecision, {
hasOpenBotPull: false,
labels: ['triage:already-available', 'triage:admitted', 'ready-for-human'],
body: '@netcatty-bot 请重新分流,这个能力其实还没有。',
}),
{
kind: 'issue_classify',
reason: 'actionable maintainer bot mention without open automation PR',
},
);
// Maintainer who is also the issue author still gets the maintainer reason.
const maintainerAuthor = auto.decideIssueCommentRoute({
labels: ['triage:already-available', 'ready-for-human'],
commenterLogin: 'maintainer',
issueAuthorLogin: 'maintainer',
commenterAssociation: 'OWNER',
body: '@netcatty-bot 请重新分流,这个能力其实还没有。',
});
assert.deepEqual(maintainerAuthor, {
kind: 'issue_followup',
reason: 'maintainer mentioned issue bot',
});
assert.deepEqual(
auto.refineIssueCommentRoute(maintainerAuthor, {
hasOpenBotPull: false,
labels: ['triage:already-available', 'ready-for-human'],
body: '@netcatty-bot 请重新分流,这个能力其实还没有。',
}),
{
kind: 'issue_classify',
reason: 'actionable maintainer bot mention without open automation PR',
},
);
// Author follow-ups on the same labels still stay on follow-up (no re-close loop).
const authorDecision = {
kind: 'issue_followup',
reason: 'author follow-up on managed issue',
};
assert.equal(
auto.refineIssueCommentRoute(authorDecision, {
hasOpenBotPull: false,
labels: ['triage:already-available', 'ready-for-human'],
body: '@netcatty-bot 请重新分流,这个能力其实还没有。',
}),
authorDecision,
);
// Non-maintainer author + @bot still routes as author follow-up (not reclassify).
assert.deepEqual(
auto.decideIssueCommentRoute({
labels: ['triage:already-available', 'ready-for-human'],
commenterLogin: 'alice',
issueAuthorLogin: 'alice',
commenterAssociation: 'NONE',
body: '@netcatty-bot 请重新分流,这个能力其实还没有。',
}),
{
kind: 'issue_followup',
reason: 'author follow-up on managed issue',
},
);
});
test('decideIssueCommentRoute ignores automation actors and unmanaged chatter', () => {
assert.equal(
auto.decideIssueCommentRoute({
@@ -1135,7 +947,6 @@ test('protected path reports replace stale data and ignore ordinary source files
test('markNeedsHuman ignores forged dedupe markers from untrusted commenters', async () => {
let created = 0;
let lastUpdate = null;
let comments = [{
user: { login: 'mallory' },
body: '<!-- cursor-implement-failure:base=abc;kind=no_changes -->',
@@ -1143,21 +954,8 @@ test('markNeedsHuman ignores forged dedupe markers from untrusted commenters', a
const github = {
rest: {
issues: {
get: async () => ({
data: {
number: 42,
state: 'open',
labels: [
{ name: 'triage:already-available' },
{ name: 'triage:admitted' },
{ name: 'ready-for-agent' },
],
},
}),
update: async (args) => {
lastUpdate = args;
return { data: {} };
},
get: async () => ({ data: { number: 42, labels: [] } }),
update: async () => ({ data: {} }),
listComments: Symbol('listComments'),
createComment: async () => { created += 1; return { data: {} }; },
},
@@ -1174,10 +972,6 @@ test('markNeedsHuman ignores forged dedupe markers from untrusted commenters', a
const first = await auto.markNeedsHuman(args);
assert.equal(first.commented, true);
assert.equal(created, 1);
assert.ok(lastUpdate.labels.includes('ready-for-human'));
assert.ok(lastUpdate.labels.includes('triage:admitted'));
assert.ok(lastUpdate.labels.includes('triage:already-available'));
assert.ok(!lastUpdate.labels.includes('ready-for-agent'));
comments = [{
user: { login: 'netcatty-bot' },
@@ -1188,128 +982,6 @@ test('markNeedsHuman ignores forged dedupe markers from untrusted commenters', a
assert.equal(created, 1);
});
test('applyReadyForHumanHandoff hands open auto-closed issues to humans', async () => {
let update = null;
let commentBody = '';
const github = {
rest: {
issues: {
get: async () => ({
data: {
number: 2673,
state: 'open',
title: '[Bug] 不能连接本地网络',
body: '本地网络权限',
labels: [
{ name: 'triage' },
{ name: 'triage:admitted' },
{ name: 'triage:already-available' },
],
},
}),
update: async (args) => {
update = args;
return { data: {} };
},
listComments: Symbol('listComments'),
createComment: async (args) => {
commentBody = args.body;
return { data: {} };
},
},
},
paginate: async () => [],
};
const result = await auto.applyReadyForHumanHandoff({
github,
context: { repo: { owner: 'binaricat', repo: 'Netcatty' } },
issueNumber: 2673,
});
assert.equal(result.commented, true);
assert.equal(update.state, undefined);
assert.ok(update.labels.includes('ready-for-human'));
assert.ok(update.labels.includes('triage:already-available'));
assert.match(commentBody, /cursor-reopen-handoff/);
assert.match(commentBody, /重新打开/);
});
test('applyReadyForHumanHandoff skips when auto-close labels were cleared', async () => {
let updated = false;
const github = {
rest: {
issues: {
get: async () => ({
data: {
number: 2673,
state: 'open',
title: '[Bug] 不能连接本地网络',
body: '本地网络权限',
labels: [
{ name: 'triage' },
{ name: 'triage:admitted' },
{ name: 'ready-for-agent' },
],
},
}),
update: async () => {
updated = true;
return { data: {} };
},
listComments: Symbol('listComments'),
createComment: async () => ({ data: {} }),
},
},
paginate: async () => [],
};
const result = await auto.applyReadyForHumanHandoff({
github,
context: { repo: { owner: 'binaricat', repo: 'Netcatty' } },
issueNumber: 2673,
});
assert.equal(result.skipped, true);
assert.equal(result.commented, false);
assert.equal(updated, false);
});
test('applyReadyForHumanHandoff skips when maintainer already re-closed', async () => {
let updated = false;
const github = {
rest: {
issues: {
get: async () => ({
data: {
number: 2673,
state: 'closed',
title: '[Bug] 不能连接本地网络',
body: '本地网络权限',
labels: [
{ name: 'triage' },
{ name: 'triage:admitted' },
{ name: 'triage:already-available' },
],
},
}),
update: async () => {
updated = true;
return { data: {} };
},
listComments: Symbol('listComments'),
createComment: async () => ({ data: {} }),
},
},
paginate: async () => [],
};
const result = await auto.applyReadyForHumanHandoff({
github,
context: { repo: { owner: 'binaricat', repo: 'Netcatty' } },
issueNumber: 2673,
});
assert.equal(result.skipped, true);
assert.equal(result.reason, 'issue already closed');
assert.equal(result.commented, false);
assert.equal(updated, false);
});
test('workflow cleans source labels after eligible PR close and dedupes clean notices', () => {
const workflow = fs.readFileSync(
path.join(__dirname, '..', '.github', 'workflows', 'cursor-automation.yml'),
@@ -1328,14 +1000,6 @@ test('workflow cleans source labels after eligible PR close and dedupes clean no
assert.match(workflow, /does not currently close issue/);
assert.match(workflow, /findOpenPullForIssue/);
assert.match(workflow, /refineIssueCommentRoute/);
assert.match(
workflow,
/refineIssueCommentRoute\(decision, \{\n\s+hasOpenBotPull: Boolean\(pull\),\n\s+hasOpenRelatedPull: Boolean\(relatedPull\),\n\s+body: comment\.body,\n\s+labels,/,
);
assert.match(workflow, /decideIssuesEventRoute/);
assert.match(workflow, /kind == 'ready_for_human_handoff'/);
assert.match(workflow, /applyReadyForHumanHandoff/);
assert.match(workflow, /REOPEN_HANDOFF_MARKER/);
assert.match(workflow, /issueNumber: issue\.number,\n\s+includeRelated: true/);
assert.match(workflow, /reconcile_closed_handoffs:/);
assert.match(workflow, /shouldRetryIssueHandoff/);
@@ -1354,8 +1018,6 @@ test('workflow cleans source labels after eligible PR close and dedupes clean no
route,
/sameRepo\s*&&\s*\n\s*context\.payload\.action === 'closed'/,
);
assert.match(route, /decideIssuesEventRoute/);
assert.match(route, /ready_for_human_handoff:/);
assert.match(workflow, /Follow-up changed before the simple reply; dispatched a fresh review/);
assert.match(workflow, /is merged; skipped stale follow-up handoff state changes/);
assert.match(workflow, /trusted_comment_bodies/);
@@ -2631,9 +2293,6 @@ test('every code-writing Cursor path compares exact-base failures and preserves
assert.match(codex, /fix-test-comparison\.json/);
assert.match(codex, /steps\.fixpatch\.outputs\.artifact_ready == 'true'/);
assert.match(codex, /fix-candidate-tests\.log/);
assert.match(codex, /fix-base-tests\.log/);
assert.match(implement, /base-tests\.log/);
assert.match(followup, /followup-base-tests\.log/);
assert.ok(
codex.indexOf('name: Install test shell dependencies') <
codex.indexOf('name: Capture Codex-fix exact-base test baseline'),
@@ -4139,101 +3798,6 @@ test('shouldSkipExternalCodexRerequest matches trusted head sha marker only', ()
);
});
test('shouldSkipExternalCodexRerequest honors head pins; ignores plain unpinned @codex', () => {
const sha = 'deadbeefcafebabe000000000000000000000001';
const short = sha.slice(0, 12);
// Automation request with head pin but no external marker still dedupes.
assert.equal(
auto.shouldSkipExternalCodexRerequest({
headSha: sha,
ownActors: 'binaricat,netcatty-bot,github-actions[bot]',
existingComments: [
{
user: { login: 'binaricat' },
body: auto.buildCodexReviewRequestComment(1, sha),
},
],
}),
true,
);
// Short SHA pin matches full head.
assert.equal(
auto.shouldSkipExternalCodexRerequest({
headSha: sha,
ownActors: 'binaricat',
existingComments: [
{
user: { login: 'binaricat' },
body: `<!-- cursor-automation -->\n\n@codex review\n\n<!-- cursor-codex-head:${short} -->`,
},
],
}),
true,
);
// Plain unpinned @codex never suppresses — cannot know which SHA it meant.
assert.equal(
auto.shouldSkipExternalCodexRerequest({
headSha: sha,
ownActors: 'binaricat',
notBefore: '2026-08-05T14:00:00Z',
existingComments: [
{
user: { login: 'cursor[bot]' },
created_at: '2026-08-05T14:00:30Z',
body: '@codex review',
},
],
}),
false,
);
assert.equal(
auto.shouldSkipExternalCodexRerequest({
headSha: sha,
ownActors: 'binaricat',
existingComments: [
{
user: { login: 'binaricat' },
created_at: '2026-08-05T14:00:10Z',
body: '@codex review',
},
],
}),
false,
);
// Connector clean summary is not a request.
assert.equal(
auto.shouldSkipExternalCodexRerequest({
headSha: sha,
ownActors: 'binaricat',
existingComments: [
{
user: { login: 'chatgpt-codex-connector[bot]' },
created_at: '2026-08-05T14:00:59Z',
body: "Codex Review: Didn't find any major issues.\n\n**Reviewed commit:** `deadbeef`",
},
],
}),
false,
);
// Different head pin does not skip.
assert.equal(
auto.shouldSkipExternalCodexRerequest({
headSha: sha,
ownActors: 'binaricat',
existingComments: [
{
user: { login: 'binaricat' },
body: auto.buildCodexReviewRequestComment(
1,
'ffffffffffffffffffffffffffffffffffffffff',
),
},
],
}),
false,
);
});
test('parseCodexReviewOutcome accepts clean reaction without summary text', () => {
const outcome = auto.parseCodexReviewOutcome({
summaryText: '',