diff --git a/AGENTS.md b/AGENTS.md
index d623ba59bbf1..70fc9bc8d647 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1284,14 +1284,15 @@ def profile_env(tmp_path, monkeypatch):
### Python
**ALWAYS use `scripts/run_tests.sh`** — do not call `pytest` directly. The script enforces
hermetic environment parity with CI (unset credential vars, TZ=UTC, LANG=C.UTF-8,
-`-n auto` xdist workers, in-tree subprocess-isolation plugin). Direct `pytest`
+per-file subprocess isolation via `scripts/run_tests_parallel.py` — no xdist,
+worker count auto-scaled from CPU count). Direct `pytest`
on a 16+ core developer machine with API keys set diverges from CI in ways
that have caused multiple "works locally, fails in CI" incidents (and the reverse).
```bash
scripts/run_tests.sh # full suite, CI-parity
scripts/run_tests.sh tests/gateway/ # one directory
-scripts/run_tests.sh tests/agent/test_foo.py::test_x # one test
+scripts/run_tests.sh tests/agent/test_foo.py -k test_x # one test (file + -k; the runner is file-granular)
scripts/run_tests.sh -v --tb=long # pass-through pytest flags
```
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 46581d820037..4fbf5b5a3da6 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -201,7 +201,8 @@ ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes
### Run tests
```bash
-# Preferred — matches CI (hermetic env, 4 xdist workers); see AGENTS.md
+# Preferred — matches CI (hermetic `env -i`, per-file subprocess isolation
+# via run_tests_parallel.py, worker count auto-scaled); see AGENTS.md
scripts/run_tests.sh
# Alternative (activate the venv first). The wrapper is still recommended
@@ -848,7 +849,7 @@ that touches the OS, assume *any* platform can hit your code path.
Tests that use POSIX-only syscalls need a skip marker. Common ones:
- Symlinks → `@pytest.mark.skipif(sys.platform == "win32", ...)`
- `0o600` file modes → `@pytest.mark.skipif(sys.platform.startswith("win"), ...)`
-- `signal.SIGALRM` → Unix-only (see `tests/conftest.py::_enforce_test_timeout`)
+- `signal.SIGALRM` → Unix-only (per-test timeouts no longer use it directly; see the win32 timeout-method shim in `tests/conftest.py::pytest_configure`)
- `os.setsid` / `os.fork` → Unix-only
- Live Winsock / Windows-specific regression tests →
`@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific regression")`
diff --git a/apps/bootstrap-installer/src-tauri/src/update.rs b/apps/bootstrap-installer/src-tauri/src/update.rs
index 63a5bfe8d71e..3fada182d029 100644
--- a/apps/bootstrap-installer/src-tauri/src/update.rs
+++ b/apps/bootstrap-installer/src-tauri/src/update.rs
@@ -895,6 +895,17 @@ fn update_child_env(install_root: &Path) -> Vec<(String, OsString)> {
// a frozen stage, and users cancel a healthy update. Force line-by-line
// output instead.
envs.push(("PYTHONUNBUFFERED".to_string(), OsString::from("1")));
+ // We hold the update-in-progress marker for this whole run, and the
+ // `hermes update` child claims that SAME lock (hermes_cli/update_lock.py).
+ // Name our pid so the child recognizes the live holder as its own
+ // orchestrator and runs under our claim — without this every GUI update
+ // refuses its parent's marker with exit 2 ("Hermes is still running")
+ // and no number of retries can ever succeed. Keep the variable name in
+ // sync with HANDOFF_PID_ENV in hermes_cli/update_lock.py.
+ envs.push((
+ "HERMES_UPDATE_HANDOFF_PID".to_string(),
+ OsString::from(std::process::id().to_string()),
+ ));
if let Some(path) = path_with_prepended_entries(&[
hermes_home.join("node").join("bin"),
venv_bin_dir(install_root),
@@ -1218,6 +1229,17 @@ mod tests {
);
}
+ #[test]
+ fn update_child_env_names_our_pid_for_the_lock_handoff() {
+ let envs = update_child_env(Path::new("/x/hermes-agent"));
+ assert!(
+ envs.iter().any(|(k, v)| k == "HERMES_UPDATE_HANDOFF_PID"
+ && v.to_str() == Some(std::process::id().to_string().as_str())),
+ "the hermes update child claims the same marker we hold; without our pid \
+ it refuses its own parent's lock and every GUI update dead-ends on exit 2"
+ );
+ }
+
#[test]
fn lock_probe_paths_include_desktop_app_payload() {
let root = Path::new("/x/hermes-agent");
diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts
index 344b149d0d10..121de24f6985 100644
--- a/apps/desktop/electron/main.ts
+++ b/apps/desktop/electron/main.ts
@@ -5155,6 +5155,20 @@ function sendClosePreviewRequested() {
webContents.send('hermes:close-preview-requested')
}
+function sendOpenFolderRequested() {
+ if (!mainWindow || mainWindow.isDestroyed()) {
+ return
+ }
+
+ const webContents = mainWindow.webContents
+
+ if (!webContents || webContents.isDestroyed()) {
+ return
+ }
+
+ webContents.send('hermes:open-folder-requested')
+}
+
// Tell the renderer the machine just woke. Sleep silently drops the
// renderer's WebSocket to the local backend; the renderer reconnects on this
// signal so the chat composer doesn't stay stuck on "Starting Hermes...".
@@ -5272,6 +5286,10 @@ function buildApplicationMenu() {
// a menu accelerator would fight the rebind panel and (on macOS) be
// swallowed before the renderer sees it. Here purely for discoverability.
{ click: () => createInstanceWindow(), label: 'New Window' },
+ // Same no-accelerator rationale: ⌘O is the rebindable renderer keybind
+ // (workspace.openFolder). Clicking runs the same open-folder-as-project
+ // flow through the renderer.
+ { click: () => sendOpenFolderRequested(), label: 'Open Folder…' },
{ type: 'separator' },
IS_MAC
? {
diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts
index b82a6e1484b0..8822efd2a993 100644
--- a/apps/desktop/electron/preload.ts
+++ b/apps/desktop/electron/preload.ts
@@ -212,6 +212,12 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
return () => ipcRenderer.removeListener('hermes:close-preview-requested', listener)
},
+ onOpenFolderRequested: callback => {
+ const listener = () => callback()
+ ipcRenderer.on('hermes:open-folder-requested', listener)
+
+ return () => ipcRenderer.removeListener('hermes:open-folder-requested', listener)
+ },
onOpenUpdatesRequested: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:open-updates', listener)
diff --git a/apps/desktop/scripts/probe-command-palette.mjs b/apps/desktop/scripts/probe-command-palette.mjs
new file mode 100644
index 000000000000..1b48d81df8ad
--- /dev/null
+++ b/apps/desktop/scripts/probe-command-palette.mjs
@@ -0,0 +1,146 @@
+// ⌘K open latency, measured in-page (no CDP round-trip in the number).
+//
+// node scripts/probe-command-palette.mjs [--port 9222] [--rounds 8]
+//
+// Reports, per round, the time from the keydown the app actually receives to:
+// frame_ms — the dialog frame + input in the DOM and painted (what "instant"
+// means: the overlay owes you a frame immediately)
+// rows_ms — the row list painted (may lag frame_ms; rows are deferred)
+// plus any long tasks in the window, so a slow open is attributable.
+import { CDP, sleep } from './perf/lib/cdp.mjs'
+
+const args = process.argv.slice(2)
+const flag = name => {
+ const i = args.indexOf(`--${name}`)
+
+ return i >= 0 ? args[i + 1] : undefined
+}
+
+const port = Number(flag('port') ?? 9222)
+const rounds = Number(flag('rounds') ?? 8)
+
+const cdp = await CDP.connect({ port })
+
+await cdp.send('Runtime.enable')
+
+const INSTALL = `
+ (() => {
+ if (window.__CMDK__) window.__CMDK__.stop()
+
+ const state = { t0: null, frame: null, rows: 0, rowsAt: null, tasks: [], armed: false }
+
+ // Time from the keydown the APP receives — excludes CDP transport, so the
+ // number is what a user's finger actually experiences.
+ const onKey = e => {
+ if (state.armed && (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
+ state.t0 = performance.now()
+ state.armed = false
+ }
+ }
+
+ window.addEventListener('keydown', onKey, true)
+
+ const obs = new MutationObserver(() => {
+ if (state.t0 === null) return
+ if (state.frame === null && document.querySelector('[cmdk-input]')) {
+ state.frame = performance.now() - state.t0
+ }
+ const n = document.querySelectorAll('[cmdk-item]').length
+ if (n > state.rows) { state.rows = n; state.rowsAt = performance.now() - state.t0 }
+ })
+
+ obs.observe(document.body, { childList: true, subtree: true })
+
+ const po = new PerformanceObserver(list => {
+ for (const e of list.getEntries()) state.tasks.push({ start: e.startTime, dur: Math.round(e.duration) })
+ })
+
+ try { po.observe({ entryTypes: ['longtask'] }) } catch {}
+
+ window.__CMDK__ = {
+ arm: () => { state.t0 = null; state.frame = null; state.rows = 0; state.rowsAt = null; state.tasks = []; state.armed = true },
+ read: () => ({
+ frame_ms: state.frame === null ? -1 : Math.round(state.frame),
+ rows_ms: state.rowsAt === null ? -1 : Math.round(state.rowsAt),
+ rows: state.rows,
+ longtask_ms: state.t0 === null ? 0 : state.tasks.filter(t => t.start >= state.t0).reduce((s, t) => s + t.dur, 0)
+ }),
+ stop: () => { window.removeEventListener('keydown', onKey, true); obs.disconnect(); po.disconnect() }
+ }
+
+ return true
+ })()
+`
+
+// Settle: frame painted AND rows stopped growing for two frames.
+const WAIT = `
+ new Promise(resolve => {
+ let stable = 0
+ let last = -1
+ const started = performance.now()
+ const tick = () => {
+ const r = window.__CMDK__.read()
+ if (r.frame_ms >= 0 && r.rows === last && r.rows > 0) {
+ if (++stable >= 2) { resolve(r); return }
+ } else { stable = 0 }
+ last = r.rows
+ if (performance.now() - started > 8000) { resolve(window.__CMDK__.read()); return }
+ requestAnimationFrame(tick)
+ }
+ requestAnimationFrame(tick)
+ })
+`
+
+const key = async type =>
+ cdp.send('Input.dispatchKeyEvent', {
+ type,
+ key: 'k',
+ code: 'KeyK',
+ windowsVirtualKeyCode: 75,
+ nativeVirtualKeyCode: 75,
+ modifiers: 4
+ })
+
+const esc = async () => {
+ for (const type of ['keyDown', 'keyUp']) {
+ await cdp.send('Input.dispatchKeyEvent', { type, key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 })
+ }
+
+ await sleep(400)
+}
+
+await cdp.eval(INSTALL)
+await esc()
+
+const samples = []
+
+for (let i = 0; i < rounds; i++) {
+ await sleep(250)
+ await cdp.eval('window.__CMDK__.arm()')
+ await key('rawKeyDown')
+ await key('keyUp')
+ const r = await cdp.eval(WAIT)
+ samples.push(r)
+ console.log(`round ${i}:`, r)
+ await esc()
+}
+
+await cdp.eval('window.__CMDK__.stop()')
+
+const stat = k => {
+ const v = samples.map(s => s[k]).filter(n => n >= 0).sort((a, b) => a - b)
+
+ if (!v.length) return null
+
+ return {
+ min: v[0],
+ median: v[Math.floor(v.length / 2)],
+ max: v[v.length - 1]
+ }
+}
+
+console.log('\nkeydown → dialog frame painted (ms):', stat('frame_ms'))
+console.log('keydown → rows painted (ms):', stat('rows_ms'))
+console.log('long-task time in window (ms):', stat('longtask_ms'))
+
+cdp.close()
diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts
index d981d6435d8b..82464ffd5d4d 100644
--- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts
+++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts
@@ -121,7 +121,7 @@ export function useComposerDraft({
const editor = editorRef.current
if (editor) {
- renderComposerContents(editor, next)
+ renderComposerContents(editor, next, { trailingCommitted: true })
placeCaretEnd(editor)
}
@@ -265,7 +265,7 @@ export function useComposerDraft({
const editor = editorRef.current
if (editor && document.activeElement !== editor && composerPlainText(editor) !== text) {
- renderComposerContents(editor, text)
+ renderComposerContents(editor, text, { trailingCommitted: true })
}
if (isBrowsingHistory(sessionIdRef.current) || queueEditRef.current) {
diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx
index dbcef7bfe288..19b71ed65438 100644
--- a/apps/desktop/src/app/chat/composer/index.tsx
+++ b/apps/desktop/src/app/chat/composer/index.tsx
@@ -1043,9 +1043,7 @@ export function ChatBar({
- {isHelpHint &&
}
- {trigger && !argStageEmpty && (
-
- )}
- {!poppedOut && (
-
- )}
- {/* Drag region: covers the transparent grab margin around the surface.
+ {isHelpHint &&
}
+ {trigger && !argStageEmpty && (
+
+ )}
+ {!poppedOut && (
+
+ )}
+ {/* Drag region: covers the transparent grab margin around the surface.
The surface sits on top (z-4) so only the exposed ring receives this
element's hover/cursor — grab cursor + a diagonal hatch (/////)
appear when you hover the draggable margin, never over the input.
The hatch pattern + opacity ladder live in styles.css. */}
- {popoutAllowed && (
-
- )}
-
-
+ {popoutAllowed && (
-
+ )}
+
- {/* Contribution seams: banners above, a row below, inline
- additions beside the "+" menu and before the controls.
- All four render nothing until something contributes. */}
-
-
-
- {queueEdit && editingQueuedPrompt && (
-
-
- {t.composer.editingQueuedInComposer}
-
-
- exitQueuedEdit('cancel')}
- type="button"
- variant="ghost"
- >
- {t.common.cancel}
-
- exitQueuedEdit('save')}
- type="button"
- >
- {t.common.save}
-
-
-
- )}
- {attachments.length > 0 &&
}
+
+
-
- {contextMenu}
-
-
-
{input}
-
-
- {controls}
+ {/* Contribution seams: banners above, a row below, inline
+ additions beside the "+" menu and before the controls.
+ All four render nothing until something contributes. */}
+
+
+
+ {queueEdit && editingQueuedPrompt && (
+
+
+ {t.composer.editingQueuedInComposer}
+
+
+ exitQueuedEdit('cancel')}
+ type="button"
+ variant="ghost"
+ >
+ {t.common.cancel}
+
+ exitQueuedEdit('save')}
+ type="button"
+ >
+ {t.common.save}
+
+
+
+ )}
+ {attachments.length > 0 &&
}
+
+
+ {contextMenu}
+
+
+
{input}
+
+
+ {controls}
+
+
-
-
{/* Underside: chrome-free strip BELOW the composer. Outside the root
for the same reason as the micro actions — it must not fall inside
diff --git a/apps/desktop/src/app/chat/composer/rich-editor.test.ts b/apps/desktop/src/app/chat/composer/rich-editor.test.ts
index e55f24e8cf12..6c3d2f873326 100644
--- a/apps/desktop/src/app/chat/composer/rich-editor.test.ts
+++ b/apps/desktop/src/app/chat/composer/rich-editor.test.ts
@@ -230,6 +230,82 @@ describe('insertComposerContentsAtCaret', () => {
editor.remove()
})
+
+ // A directive typed by hand chips; the same directive pasted has to chip too,
+ // or copy/pasting a prompt silently drops every command in it.
+ it('chips a pasted slash command, including one that ends the paste', () => {
+ const editor = document.createElement('div')
+ editor.dataset.slot = RICH_INPUT_SLOT
+ document.body.append(editor)
+ caretIn(editor)
+
+ insertComposerContentsAtCaret(editor, '/some-skill')
+
+ expect(editor.querySelector('[data-slash-kind]')?.getAttribute('data-ref-text')).toBe('/some-skill')
+ // Committed pills carry the trailing space the typed path appends, so a
+ // later full re-render doesn't read the token as half-typed.
+ expect(composerPlainText(editor)).toBe('/some-skill ')
+
+ editor.remove()
+ })
+
+ it('chips a skill named mid-paste alongside a ref', () => {
+ const editor = document.createElement('div')
+ editor.dataset.slot = RICH_INPUT_SLOT
+ document.body.append(editor)
+ caretIn(editor)
+
+ insertComposerContentsAtCaret(editor, 'clean @file:`a.ts` with /some-skill then ship')
+
+ expect(editor.querySelectorAll('[data-slash-kind]').length).toBe(1)
+ expect(editor.querySelectorAll('[data-ref-kind="file"]').length).toBe(1)
+ expect(composerPlainText(editor)).toBe('clean @file:`a.ts` with /some-skill then ship')
+
+ editor.remove()
+ })
+
+ it('leaves a pasted path alone — /usr/local is not a command', () => {
+ const editor = document.createElement('div')
+ editor.dataset.slot = RICH_INPUT_SLOT
+ document.body.append(editor)
+ caretIn(editor)
+
+ insertComposerContentsAtCaret(editor, 'see /usr/local/bin and /goal ship it')
+
+ expect(editor.querySelector('[data-slash-kind]')).toBeNull()
+ expect(composerPlainText(editor)).toBe('see /usr/local/bin and /goal ship it')
+
+ editor.remove()
+ })
+
+ it('does not chip a command pasted against a word — foo/clean is not a command', () => {
+ const editor = document.createElement('div')
+ editor.dataset.slot = RICH_INPUT_SLOT
+ editor.textContent = 'foo'
+ document.body.append(editor)
+ caretIn(editor)
+
+ insertComposerContentsAtCaret(editor, '/some-skill')
+
+ expect(editor.querySelector('[data-slash-kind]')).toBeNull()
+ expect(composerPlainText(editor)).toBe('foo/some-skill')
+
+ editor.remove()
+ })
+
+ it('chips a command pasted right after an existing chip', () => {
+ const editor = document.createElement('div')
+ editor.dataset.slot = RICH_INPUT_SLOT
+ editor.append(refChipElement('file', '`a.ts`'))
+ document.body.append(editor)
+ caretIn(editor)
+
+ insertComposerContentsAtCaret(editor, '/some-skill')
+
+ expect(editor.querySelector('[data-slash-kind]')).not.toBeNull()
+
+ editor.remove()
+ })
})
describe('replaceBeforeCaret', () => {
diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts
index bc6a1f26bc83..9958c3a4b096 100644
--- a/apps/desktop/src/app/chat/composer/rich-editor.ts
+++ b/apps/desktop/src/app/chat/composer/rich-editor.ts
@@ -16,22 +16,13 @@ import {
type SlashChipKind,
slashIconElement
} from '@/components/assistant-ui/directive-text'
-import {
- desktopSlashCommandArgumentMode,
- isDesktopSlashCommand,
- resolveDesktopCommand
-} from '@/lib/desktop-slash-commands'
+
+import { slashCommandMatches, type SlashCommandScanOptions } from './slash-refs'
export const RICH_INPUT_SLOT = 'composer-rich-input'
export const REF_RE = /@(file|folder|url|image|tool|line|terminal|session):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
-/** A committed leading slash command: `/name` followed by whitespace. The
- * whitespace requirement is what separates a committed command (chips always
- * serialize with their auto-inserted trailing space) from one still being
- * typed, which must stay editable text. */
-const LEADING_SLASH_COMMAND_RE = /^\/[a-zA-Z][\w-]*(?=\s)/
-
const ESC: Record
= { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
export function escapeHtml(value: string) {
@@ -123,42 +114,59 @@ function appendTextWithBreaks(target: DocumentFragment | HTMLElement, text: stri
})
}
-export function appendComposerContents(target: DocumentFragment | HTMLElement, text: string) {
+/** Every span of `text` that renders as a chip, in source order. */
+function chipSpans(text: string, options: SlashCommandScanOptions) {
+ REF_RE.lastIndex = 0
+
+ const refs = Array.from(text.matchAll(REF_RE)).map(match => {
+ const start = match.index ?? 0
+
+ return { end: start + match[0].length, node: () => refChipElement(match[1] || 'file', match[2] || ''), start }
+ })
+
+ const commands = slashCommandMatches(text, options).map(match => ({
+ end: match.end,
+ node: () => slashChipElement(match.command, match.kind),
+ start: match.start
+ }))
+
+ return [...refs, ...commands].sort((a, b) => a.start - b.start)
+}
+
+/** Build the chip/text DOM for `text`. Directives hydrate back to their pills —
+ * `@kind:value` refs and `/command` invocations both — so text that arrives
+ * whole (a paste, a restored draft, an undo step, a rebuilt line) carries the
+ * same chips the typed path would have committed. */
+export function appendComposerContents(
+ target: DocumentFragment | HTMLElement,
+ text: string,
+ options: SlashCommandScanOptions = {}
+) {
let cursor = 0
- REF_RE.lastIndex = 0
+ for (const span of chipSpans(text, options)) {
+ // A `@` ref wins an overlap: a command token can't contain an `@`, so the
+ // only way spans collide is a slash inside a quoted ref value
+ // (`` @url:`a /clean` ``), which belongs to that value.
+ if (span.start < cursor) {
+ continue
+ }
- for (const match of text.matchAll(REF_RE)) {
- const index = match.index ?? 0
- appendTextWithBreaks(target, text.slice(cursor, index))
- target.append(refChipElement(match[1] || 'file', match[2] || ''))
- cursor = index + match[0].length
+ appendTextWithBreaks(target, text.slice(cursor, span.start))
+ target.append(span.node())
+ cursor = span.end
}
appendTextWithBreaks(target, text.slice(cursor))
}
-export function renderComposerContents(target: HTMLElement, text: string) {
+export function renderComposerContents(target: HTMLElement, text: string, options?: SlashCommandScanOptions) {
target.replaceChildren()
- // A leading `/command` hydrates back to its pill — parity with REF_RE for
- // `@` refs, so a full re-render from serialized text (draft restore, undo,
- // the trigger commit fallback) doesn't demote a committed command chip to
- // plain text. Only commands with NO argument stage qualify (skills, quick
- // commands, no-arg built-ins): their committed pill is exactly the bare
- // `/name`, so the boundary is unambiguous. Arg-taking commands (`/goal ship
- // it`, `/personality alice`) stay text — their tail may be prose that was
- // never committed. The trailing whitespace is load-bearing too: a committed
- // pill always serializes with its auto-inserted space, while a half-typed
- // `/wor` must stay editable text.
- const command = LEADING_SLASH_COMMAND_RE.exec(text)?.[0]
-
- if (command && isDesktopSlashCommand(command) && desktopSlashCommandArgumentMode(command) === null) {
- target.append(slashChipElement(command, resolveDesktopCommand(command) ? 'command' : 'skill'))
- text = text.slice(command.length)
- }
-
- appendComposerContents(target, text)
+ // Defaults to live editing, where a token ending the text is still being
+ // typed (`/wor`) and must stay editable. Callers repainting inert text (a
+ // restored draft, a sent message opened for edit) pass `trailingCommitted`.
+ appendComposerContents(target, text, options)
}
/** Caret range when the selection lives inside `editor`; else null. */
@@ -173,20 +181,79 @@ function composerSelectionRange(editor: HTMLElement) {
return { range, selection }
}
-/** Insert text at the caret (replacing any selection), with any `@kind:value`
- * directives in it landing as chips. Pastes use this instead of
- * `execCommand('insertText')` — Chromium's editing pipeline is ~O(n²) on large
- * multiline blobs. */
+/** Serialized text from the editor's start up to (`container`, `offset`).
+ *
+ * Chips are ATOMIC here: each contributes an object-replacement placeholder
+ * rather than leaking its label text, and a contributes a newline. That
+ * makes a chip edge read as a token boundary, which is what both trigger
+ * detection and directive recognition need. */
+export function serializeTextBefore(editor: HTMLElement, container: Node, offset: number): string {
+ const probe = document.createRange()
+
+ probe.selectNodeContents(editor)
+ probe.setEnd(container, offset)
+
+ const scratch = document.createElement('div')
+
+ scratch.append(probe.cloneContents())
+
+ for (const chip of scratch.querySelectorAll('[data-ref-text]')) {
+ chip.replaceWith('\uFFFC')
+ }
+
+ for (const br of scratch.querySelectorAll('br')) {
+ br.replaceWith('\n')
+ }
+
+ return scratch.textContent ?? ''
+}
+
+/** True when the insertion point starts a token — the editor's start, or after
+ * whitespace or a chip. `foo` + a pasted `/clean` is `foo/clean`, not a
+ * command; `foo ` + the same paste is. */
+function atTokenBoundary(editor: HTMLElement, range: Range | null): boolean {
+ // No caret means the insert lands at the end, so the question is about the
+ // editor's last character either way.
+ const before = range
+ ? serializeTextBefore(editor, range.startContainer, range.startOffset)
+ : serializeTextBefore(editor, editor, editor.childNodes.length)
+
+ const last = before.slice(-1)
+
+ return !last || /[\s\uFFFC]/.test(last)
+}
+
+/** Insert text at the caret (replacing any selection), with any directives in
+ * it landing as chips. Pastes use this instead of `execCommand('insertText')`
+ * — Chromium's editing pipeline is ~O(n²) on large multiline blobs.
+ *
+ * The text arrives whole rather than typed, so a `/command` ending it is
+ * complete rather than half-written and chips like the rest. */
export function insertComposerContentsAtCaret(editor: HTMLElement, text: string) {
const hit = composerSelectionRange(editor)
const fragment = document.createDocumentFragment()
- appendComposerContents(fragment, text)
+ // Before measuring the boundary — a replaced selection puts the insertion
+ // point where the selection started, not where it ended.
+ if (hit) {
+ hit.range.deleteContents()
+ }
+
+ appendComposerContents(fragment, text, {
+ boundaryBefore: atTokenBoundary(editor, hit?.range ?? null),
+ trailingCommitted: true
+ })
+
+ // A slash pill ending the insert gets the trailing space the typed commit
+ // path appends, or the next full re-render reads it as a half-typed token
+ // and demotes it. `@` refs need no marker — REF_RE re-chips them either way.
+ if ((fragment.lastChild as HTMLElement | null)?.dataset?.slashKind) {
+ fragment.append(document.createTextNode(' '))
+ }
const tail = fragment.lastChild
if (hit) {
- hit.range.deleteContents()
hit.range.insertNode(fragment)
} else {
editor.append(fragment)
diff --git a/apps/desktop/src/app/chat/composer/slash-refs.test.ts b/apps/desktop/src/app/chat/composer/slash-refs.test.ts
new file mode 100644
index 000000000000..2b50d750875e
--- /dev/null
+++ b/apps/desktop/src/app/chat/composer/slash-refs.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest'
+
+import { slashCommandMatches } from './slash-refs'
+
+const commands = (text: string, options?: Parameters[1]) =>
+ slashCommandMatches(text, options).map(match => `${match.kind}:${match.command}`)
+
+describe('slashCommandMatches', () => {
+ it('recognizes a leading command and a skill named mid-prose', () => {
+ expect(commands('/some-skill clean this with /other-skill please')).toEqual([
+ 'skill:/some-skill',
+ 'skill:/other-skill'
+ ])
+ })
+
+ it('leaves a path alone — /usr/local/bin is not a command', () => {
+ expect(commands('see /usr/local/bin ')).toEqual([])
+ })
+
+ it('holds a trailing token as still-typed unless the text is inert', () => {
+ expect(commands('/some-skill')).toEqual([])
+ expect(commands('/some-skill', { trailingCommitted: true })).toEqual(['skill:/some-skill'])
+ })
+
+ it('leaves an arg-taking command as text — its tail may be prose', () => {
+ expect(commands('/goal ship the redesign')).toEqual([])
+ })
+
+ it('leaves a command with no desktop surface as text', () => {
+ expect(commands('/exit now')).toEqual([])
+ })
+
+ it('offers a built-in only as an invocation, never mid-message', () => {
+ // Mirrors what the popover offers: `/new` acts on the app, so it means
+ // nothing dropped into a sentence, while a skill reads as "handle this
+ // part with X".
+ expect(commands('/new ')).toEqual(['command:/new'])
+ expect(commands('start over with /new ')).toEqual([])
+ expect(commands('start over with /some-skill ')).toEqual(['skill:/some-skill'])
+ })
+
+ it('disqualifies a leading token when the text lands mid-word', () => {
+ expect(commands('/some-skill ', { boundaryBefore: false })).toEqual([])
+ })
+})
diff --git a/apps/desktop/src/app/chat/composer/slash-refs.ts b/apps/desktop/src/app/chat/composer/slash-refs.ts
new file mode 100644
index 000000000000..db0cf42adb66
--- /dev/null
+++ b/apps/desktop/src/app/chat/composer/slash-refs.ts
@@ -0,0 +1,105 @@
+/**
+ * Slash-command recognition for text the composer did not watch being typed —
+ * a paste, a restored draft, an undo step, a rebuilt line.
+ *
+ * The typed path chips a command as it's picked or accepted, so the composer
+ * agrees with what the sent message renders (`SLASH_SKILL_RE` in
+ * directive-text). Text that arrives whole never passed through that path, so
+ * it needs the same commands recognized in place — on exactly the terms the
+ * typed path would have used, or hydration invents pills the popover would
+ * never have committed.
+ */
+import type { SlashChipKind } from '@/components/assistant-ui/directive-text'
+import {
+ desktopSlashCommandArgumentMode,
+ isDesktopSlashCommand,
+ resolveDesktopCommand
+} from '@/lib/desktop-slash-commands'
+
+// A command token starts a word and doesn't continue into a path: `/usr/local`
+// is a path, not a `/usr` command. Same shape the sent message uses to decide
+// what renders as a pill, so the composer and the transcript agree.
+const SLASH_COMMAND_RE = /(?<=^|\s)\/([a-zA-Z][\w-]*)(?![\w-]*\/)/g
+
+export interface SlashCommandMatch {
+ /** The command with its leading slash, e.g. `/clean`. */
+ command: string
+ end: number
+ kind: SlashChipKind
+ start: number
+}
+
+export interface SlashCommandScanOptions {
+ /**
+ * Whether the text is preceded by a token boundary. False when it's being
+ * inserted mid-word (a paste landing against existing characters), which
+ * disqualifies a token at index 0 — `foo/clean` is not a command. It also
+ * makes that token mid-message rather than an invocation.
+ */
+ boundaryBefore?: boolean
+ /**
+ * Whether a token ending the text counts as committed. True for inert text
+ * (a paste, dropped content): nothing is being typed, so `/clean` at the end
+ * is the whole command. False while editing live, where a trailing `/wor` is
+ * a half-typed query the popover owns and must leave editable.
+ */
+ trailingCommitted?: boolean
+}
+
+/**
+ * Only commands with NO argument stage chip: their committed pill is exactly
+ * the bare `/name`, so the boundary is unambiguous. Arg-taking commands
+ * (`/goal ship it`) stay text — their tail may be prose. Commands with no
+ * desktop surface at all (`/exit`, `/config`) stay text too.
+ */
+function chippableKind(command: string): SlashChipKind | null {
+ if (!isDesktopSlashCommand(command) || desktopSlashCommandArgumentMode(command) !== null) {
+ return null
+ }
+
+ return resolveDesktopCommand(command) ? 'command' : 'skill'
+}
+
+/** Every `/command` in `text` that should render as a pill, in source order. */
+export function slashCommandMatches(text: string, options: SlashCommandScanOptions = {}): SlashCommandMatch[] {
+ const { boundaryBefore = true, trailingCommitted = false } = options
+
+ if (!text.includes('/')) {
+ return []
+ }
+
+ const matches: SlashCommandMatch[] = []
+
+ for (const match of text.matchAll(SLASH_COMMAND_RE)) {
+ const start = match.index ?? 0
+ const command = match[0]
+ const end = start + command.length
+ const after = text[end]
+
+ // A committed pill always carries its auto-inserted trailing space, which
+ // is what separates it from a token still being typed.
+ if (after === undefined ? !trailingCommitted : !/\s/.test(after)) {
+ continue
+ }
+
+ // Only the FIRST token can be an invocation, and only when the text lands
+ // on a token boundary — `foo` + a pasted `/clean` is `foo/clean`.
+ const invocation = start === 0
+
+ if (invocation && !boundaryBefore) {
+ continue
+ }
+
+ const kind = chippableKind(command)
+
+ // Later tokens are references dropped into prose, where the popover offers
+ // SKILLS alone — a built-in like `/new` acts on the app and means nothing
+ // mid-sentence. Hydration has to agree, or pasted text grows pills typing
+ // never would.
+ if (kind && (invocation || kind === 'skill')) {
+ matches.push({ command, end, kind, start })
+ }
+ }
+
+ return matches
+}
diff --git a/apps/desktop/src/app/chat/composer/text-utils.ts b/apps/desktop/src/app/chat/composer/text-utils.ts
index 19dfa8ce0d95..3224716b27c0 100644
--- a/apps/desktop/src/app/chat/composer/text-utils.ts
+++ b/apps/desktop/src/app/chat/composer/text-utils.ts
@@ -1,6 +1,8 @@
import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images'
import { $reactionsEnabled } from '@/store/reactions-enabled'
+import { serializeTextBefore } from './rich-editor'
+
export interface TriggerState {
/** True for a `/` typed mid-message — an inline skill/command reference in
* prose rather than a command invocation. Arg completion doesn't apply. */
@@ -141,23 +143,7 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null {
return null
}
- const before = range.cloneRange()
- before.selectNodeContents(editor)
- before.setEnd(range.startContainer, range.startOffset)
-
- const scratch = document.createElement('div')
-
- scratch.append(before.cloneContents())
-
- for (const chip of scratch.querySelectorAll('[data-ref-text]')) {
- chip.replaceWith('\uFFFC')
- }
-
- for (const br of scratch.querySelectorAll('br')) {
- br.replaceWith('\n')
- }
-
- return scratch.textContent ?? ''
+ return serializeTextBefore(editor, range.startContainer, range.startOffset)
}
export function detectTrigger(textBefore: string): TriggerState | null {
diff --git a/apps/desktop/src/app/chat/sidebar/chrome.tsx b/apps/desktop/src/app/chat/sidebar/chrome.tsx
index 196c71768a91..8e2da487829a 100644
--- a/apps/desktop/src/app/chat/sidebar/chrome.tsx
+++ b/apps/desktop/src/app/chat/sidebar/chrome.tsx
@@ -10,7 +10,7 @@ import { cn } from '@/lib/utils'
/** The muted slot beside a section label (loading glyph, status hint). */
export function SidebarSectionMeta({ children }: { children: React.ReactNode }) {
- return {children}
+ return {children}
}
// ── Row geometry (session row is canonical — everything composes these) ─────
diff --git a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx
index 21c43cd6c7cb..dd6988ce9165 100644
--- a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx
+++ b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx
@@ -135,7 +135,7 @@ export function SidebarCronJobsSection({
diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx b/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx
index 1b7c0bc61c20..5144919c9f3a 100644
--- a/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx
+++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx
@@ -24,7 +24,9 @@ function LaneLabel({ label, title }: { label: string; title?: string }) {
const tail = label.slice(label.length - tailLen)
return (
-
+ // overflow-hidden: the pinned tail is shrink-0, so at extreme narrow widths
+ // it must clip inside the label rather than push the trailing icons out.
+
{head}
{tail}
diff --git a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx
index 8cd060bb02a5..35174a8370e6 100644
--- a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx
+++ b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx
@@ -64,7 +64,9 @@ function SidebarSectionHeader({
{collapsible ? (
@@ -75,7 +77,7 @@ function SidebarSectionHeader({
/>
) : (
-
{labelBody}
+
{labelBody}
)}
{action}
diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx
index a0279c495ee0..260e22db9550 100644
--- a/apps/desktop/src/app/command-palette/index.tsx
+++ b/apps/desktop/src/app/command-palette/index.tsx
@@ -1,11 +1,12 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { Dialog as DialogPrimitive } from 'radix-ui'
-import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud'
import { setTerminalTakeover } from '@/app/right-sidebar/store'
+import { codiconIcon } from '@/components/ui/codicon'
import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { HighlightMatches } from '@/components/ui/highlight-matches'
import { KbdCombo } from '@/components/ui/kbd'
@@ -60,7 +61,7 @@ import {
} from '@/store/command-palette'
import { $bindings } from '@/store/keybinds'
import { openPetGenerate } from '@/store/pet-generate'
-import { requestStartWorkSession } from '@/store/projects'
+import { $projectTree, goToProject, openFolderAsProject, requestStartWorkSession } from '@/store/projects'
import { $connection } from '@/store/session'
import { runGatewayRestart } from '@/store/system-actions'
import {
@@ -103,6 +104,8 @@ interface PaletteItem {
action?: string
/** Renders a trailing check: this row IS the current setting (theme, mode). */
active?: boolean
+ /** Static trailing combo hint for a modifier-variant select (e.g. `mod+enter`). */
+ comboHint?: string
/** Muted text beside the label — state the row acts on (a version, a count). */
detail?: string
icon: IconComponent
@@ -111,6 +114,8 @@ interface PaletteItem {
keepOpen?: boolean
keywords?: string[]
label: string
+ /** Label shown while ⌘/⌃ is held — previews the modifier-variant action. */
+ modLabel?: string
/**
* When set, ⌘/⌃-select (or ⌘-Enter) opens a new tab and ⇧⌘-select pops a
* window — matching sidebar session rows. Plain select stays in-place.
@@ -230,21 +235,92 @@ const rankGroups = (groups: PaletteGroup[], search: string): PaletteGroup[] => {
// theme lists under both Light and Dark). The id suffix disambiguates.
const paletteValue = (item: PaletteItem): string => `${item.label}\u0001${item.id}`
+const EMPTY_GROUPS: PaletteGroup[] = []
+
+// Backstop only. The palette normally retires on the content's real
+// `animationend`, so the CSS owns the close duration; this just guarantees the
+// body can't stay mounted forever somewhere animations never run (jsdom,
+// `animation: none`). Deliberately longer than any plausible exit so it never
+// races the real signal and truncates the fade.
+const EXIT_FALLBACK_MS = 1000
+
+/**
+ * The palette's row list, split out so an OPENING palette paints before it
+ * renders rows. This component mounts with the portal, so `useDeferredValue`'s
+ * initial value applies per open: the first commit is the frame + input
+ * (instant), and the several-hundred-row list arrives in an interruptible
+ * follow-up render. Opening ⌘K must never wait on building the list.
+ */
+const PaletteGroups = memo(function PaletteGroups({
+ bindings,
+ groups,
+ modHeld,
+ noResultsLabel,
+ onSelectItem,
+ onSelectMods,
+ search
+}: {
+ bindings: Record
+ groups: PaletteGroup[]
+ modHeld: boolean
+ noResultsLabel: string
+ onSelectItem: (item: PaletteItem) => void
+ onSelectMods: (event: { ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }) => void
+ search: string
+}) {
+ const deferred = useDeferredValue(groups, EMPTY_GROUPS)
+ // While the rows are still catching up, an empty list means "not rendered
+ // yet", not "nothing matched" — don't flash the empty state on open.
+ const pending = deferred !== groups
+
+ return (
+ <>
+ {/* Filtering happens in rankGroups, so cmdk's own CommandEmpty
+ (keyed to its internal filter count) would never fire. */}
+ {deferred.length === 0 && !pending && (
+ {noResultsLabel}
+ )}
+ {deferred.map((group, index) => (
+
+ {group.items.map(item => (
+
+ ))}
+
+ ))}
+ >
+ )
+})
+
const PaletteRow = memo(function PaletteRow({
bindings,
item,
+ modHeld,
onSelectMods,
onSelectItem,
search
}: {
bindings: Record
item: PaletteItem
+ modHeld: boolean
onSelectMods: (event: { ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }) => void
onSelectItem: (item: PaletteItem) => void
search: string
}) {
const Icon = item.icon
- const combo = item.action ? bindings[item.action]?.[0] : undefined
+ // The row's live keybind, else a static modifier-variant hint (⌘↵). One slot,
+ // so every downstream `ml-auto` fallback below keeps working unchanged.
+ const combo = (item.action ? bindings[item.action]?.[0] : undefined) ?? item.comboHint
+ // While ⌘/⌃ is held, a row with a modifier variant previews it: the label
+ // swaps to the variant's copy so Enter reads as what it will actually do.
+ const modPreview = modHeld && Boolean(item.modLabel)
return (
-
- {/* Same per-term split as scoreItem's AND matcher, so the emphasis
- shows exactly which words earned the row its rank. */}
-
+
+ {modPreview ? (
+ item.modLabel
+ ) : (
+ /* Same per-term split as scoreItem's AND matcher, so the emphasis
+ shows exactly which words earned the row its rank. */
+
+ )}
{item.detail && {item.detail} }
- {combo && }
+ {combo && (
+
+ )}
{item.to && }
{item.active && }
@@ -272,6 +354,12 @@ const PaletteRow = memo(function PaletteRow({
// "Go to session ‹id›" jump for ids that aren't in the recent-200 list.
const SESSION_ID_RE = /^\d{8}_\d{6}_[a-f0-9]{6}$/
+// A typed/pasted folder path: absolute (`/…`) or a Windows drive (`C:\…`).
+// Deliberately NOT `~/…`: the upsert's membership check (projectIdForCwd)
+// compares literal strings against the tree's absolute paths, so an unexpanded
+// home path would always miss and double-create.
+const FOLDER_PATH_RE = /^(\/|[A-Za-z]:[/\\]).+/
+
type SessionRow = Awaited>['sessions'][number]
const toSessionEntry = (session: SessionRow): SessionEntry => ({
@@ -353,12 +441,72 @@ function themeSupportsMode(name: string, target: 'light' | 'dark'): boolean {
return target === 'dark' ? luminance(background) <= 0.5 : luminance(background) > 0.5
}
+/**
+ * ⌘K is an overlay that is stateful to itself: pressing it must open a frame
+ * immediately, and must not be held up by whatever else the shell is doing. So
+ * the mounted cost of a CLOSED palette is one store subscription and nothing
+ * else.
+ *
+ * Everything expensive — a dozen store subscriptions (connection, update
+ * status/apply, keybinds, worktrees, projects, theme, i18n), three server
+ * queries, and the group builders that assemble a few hundred rows — lives in
+ * `CommandPaletteBody`, which only exists while the palette is on screen.
+ * Before this split those hooks ran on every render of the always-mounted
+ * component: an in-flight update rewrote `$updateApply` per progress line and
+ * rebuilt the entire row set each time, for a surface nobody could see.
+ *
+ * `mounted` lags `open` by the close animation rather than tracking it exactly.
+ * Unmounting the body the instant `open` flips false would rip the content out
+ * of the tree before Radix could play `data-[state=closed]`, so the overlay
+ * would vanish instead of closing. The body reports its own exit via
+ * `onExited` (the content's real `animationend`), so nothing here has to know
+ * how long that animation is — the CSS owns the duration.
+ *
+ * The `openCount` key remounts the body per open, which is what lets local
+ * search/sub-page state reset without a close effect.
+ */
export function CommandPalette() {
- const { t } = useI18n()
const open = useStore($commandPaletteOpen)
+ const [mounted, setMounted] = useState(open)
+ const [openCount, setOpenCount] = useState(0)
+
+ const retire = useCallback(() => {
+ // Only retire the body if the palette is still closed — a reopen mid-fade
+ // must not unmount the fresh instance.
+ if (!$commandPaletteOpen.get()) {
+ setMounted(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ if (open) {
+ setOpenCount(count => count + 1)
+ setMounted(true)
+
+ return
+ }
+
+ // Safety net for environments where the exit animation never runs (jsdom,
+ // `animation: none`), so the body can't be stranded mounted. The real
+ // unmount is `onExited` below; whichever fires first wins.
+ const timer = setTimeout(retire, EXIT_FALLBACK_MS)
+
+ return () => clearTimeout(timer)
+ }, [open, retire])
+
+ return (
+
+ {mounted && }
+
+ )
+}
+
+function CommandPaletteBody({ onExited }: { onExited: () => void }) {
+ const { t } = useI18n()
const pendingPage = useStore($commandPalettePage)
const bindings = useStore($bindings)
const worktrees = useStore($repoWorktrees)
+ const projectTree = useStore($projectTree)
const navigate = useNavigate()
const { availableThemes, mode, resolvedMode, setMode, setTheme, themeName } = useTheme()
const [search, setSearch] = useState('')
@@ -409,24 +557,44 @@ export function CommandPalette() {
}
}
- // Server-backed sources for the type-to-search groups, fetched lazily while
- // the palette is open. react-query handles caching/dedup/staleness.
+ // Live ⌘/⌃-held state while the palette is open: rows with a modifier
+ // variant (projects) preview it by swapping their label. Window-level
+ // listeners because focus sits in the search input; blur clears so a
+ // ⌘-Tab away doesn't strand the preview on.
+ const [modHeld, setModHeld] = useState(false)
+
+ useEffect(() => {
+ const sync = (event: KeyboardEvent) => setModHeld(event.metaKey || event.ctrlKey)
+ const clear = () => setModHeld(false)
+
+ window.addEventListener('keydown', sync, { capture: true })
+ window.addEventListener('keyup', sync, { capture: true })
+ window.addEventListener('blur', clear)
+
+ return () => {
+ window.removeEventListener('keydown', sync, { capture: true })
+ window.removeEventListener('keyup', sync, { capture: true })
+ window.removeEventListener('blur', clear)
+ }
+ }, [])
+
+ // Server-backed sources for the type-to-search groups. This component only
+ // exists while the palette is open, so the queries are inherently lazy — no
+ // `enabled` gate needed. react-query handles caching/dedup/staleness, so a
+ // reopen paints from cache and revalidates in the background.
const configQuery = useQuery({
queryKey: ['command-palette', 'config'],
- queryFn: getHermesConfigRecord,
- enabled: open
+ queryFn: getHermesConfigRecord
})
const sessionsQuery = useQuery({
queryKey: ['command-palette', 'sessions'],
- queryFn: () => listAllProfileSessions(200, 1, 'exclude'),
- enabled: open
+ queryFn: () => listAllProfileSessions(200, 1, 'exclude')
})
const archivedQuery = useQuery({
queryKey: ['command-palette', 'archived'],
- queryFn: () => listAllProfileSessions(200, 0, 'only'),
- enabled: open
+ queryFn: () => listAllProfileSessions(200, 0, 'only')
})
const mcpServers = useMemo(() => {
@@ -440,21 +608,16 @@ export function CommandPalette() {
const sessions = useMemo(() => (sessionsQuery.data?.sessions ?? []).map(toSessionEntry), [sessionsQuery.data])
const archivedSessions = useMemo(() => (archivedQuery.data?.sessions ?? []).map(toSessionEntry), [archivedQuery.data])
- // Reset the query/sub-page on close so it reopens clean.
- useEffect(() => {
- if (!open) {
- setSearch('')
- setPage(null)
- }
- }, [open])
+ // Search/sub-page are local to a mount, and this component remounts per open
+ // (keyed by open count), so each open starts clean without a reset effect.
// Deep-link into a nested page (e.g. `/pet list` → pets picker).
useEffect(() => {
- if (open && pendingPage) {
+ if (pendingPage) {
setPage(pendingPage)
$commandPalettePage.set(null)
}
- }, [open, pendingPage])
+ }, [pendingPage])
const go = useCallback((path: string) => () => navigateToWorkspacePage(navigate, path), [navigate])
@@ -495,6 +658,36 @@ export function CommandPalette() {
const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}`
const cc = t.commandCenter
+ // Projects are the primary way the desktop scopes work, so they're jumpable
+ // from the palette. Plain select is a pure scope switch (sidebar enters the
+ // project — never spends main); ⌘-Enter / ⌘-click also starts a new session
+ // at the project root (stacked as a tab when main holds a chat), previewed
+ // by the label swap while ⌘ is held. Rows carry the project's own codicon,
+ // matching the sidebar. The pinned "Open folder…" row is the ⌘O upsert.
+ const projectGroup: PaletteGroup = {
+ heading: cc.projects,
+ items: [
+ {
+ action: 'workspace.openFolder',
+ icon: codiconIcon('folder-opened'),
+ id: 'project-open-folder',
+ keywords: ['open', 'folder', 'directory', 'project', 'add', 'import', 'workspace'],
+ label: cc.openFolder,
+ run: () => void openFolderAsProject()
+ },
+ ...projectTree.map(project => ({
+ comboHint: 'mod+enter',
+ icon: codiconIcon(project.icon || (project.isNoProject ? 'home' : 'folder-library')),
+ id: `project-${project.id}`,
+ keywords: ['project', 'workspace', 'go to', project.label, ...(project.path ? [project.path] : [])],
+ label: project.label,
+ modLabel: cc.newSessionInProject(project.label),
+ runWithEvent: (event?: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }) =>
+ goToProject(project.id, { newSession: Boolean(event?.metaKey || event?.ctrlKey) })
+ }))
+ ]
+ }
+
// The active repo's worktrees → "new conversation in ". This is the
// ⌘K-typed "I want to work on " reflex: each entry seeds a fresh
// session anchored to that worktree's checkout (requestStartWorkSession),
@@ -599,6 +792,7 @@ export function CommandPalette() {
}
]
},
+ projectGroup,
...branchGroup,
{
heading: cc.commandCenter,
@@ -714,7 +908,7 @@ export function CommandPalette() {
]
: [])
]
- }, [contributedItems, go, settingsSectionLabel, t, updateVersionLabel, worktrees])
+ }, [contributedItems, go, projectTree, settingsSectionLabel, t, updateVersionLabel, worktrees])
// The long, granular lists (settings fields, API keys, MCP servers, archived
// chats) only surface once the user types — otherwise they'd bury the
@@ -744,6 +938,23 @@ export function CommandPalette() {
})
}
+ // Paste/type an absolute folder path → open it as a project directly (the
+ // ⌘O upsert without the native picker). Same reflex as the raw-session-id
+ // row above.
+ if (FOLDER_PATH_RE.test(directId)) {
+ result.push({
+ items: [
+ {
+ icon: codiconIcon('folder-opened'),
+ id: `open-folder-${directId}`,
+ keywords: ['open', 'folder', 'project', directId],
+ label: t.commandCenter.openFolderAt(directId),
+ run: () => void openFolderAsProject(directId)
+ }
+ ]
+ })
+ }
+
// Deep-link straight to a Capabilities sub-tab. The root "Go to" entry only
// lands on the top-level Skills view; typing "mcp"/"tools"/"skills" should
// jump to the exact tab (matches the "not just the top lvl" ask).
@@ -1001,101 +1212,92 @@ export function CommandPalette() {
}
return (
-
-
- {/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */}
-
-
+ {/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */}
+
+ {
+ if (event.target === event.currentTarget && event.currentTarget.dataset.state === 'closed') {
+ onExited()
+ }
+ }}
+ >
+ {t.commandCenter.paletteTitle}
+
+ {activePage && (
+
+
+ {t.commandCenter.back}
+ /
+ {activePage.title}
+
)}
- >
- {t.commandCenter.paletteTitle}
-
- {activePage && (
-
-
- {t.commandCenter.back}
- /
- {activePage.title}
-
- )}
- {
- // Capture modifiers before cmdk's Enter fires onSelect (which
- // swipes the inviting MouseEvent and hands us nothing).
- noteSelectMods(event)
-
- if (!activePage) {
- return
- }
+ {
+ // Capture modifiers before cmdk's Enter fires onSelect (which
+ // swipes the inviting MouseEvent and hands us nothing).
+ noteSelectMods(event)
+
+ if (!activePage) {
+ return
+ }
- // In a submenu: Esc and empty-input Backspace step back out
- // instead of closing the whole palette.
- if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {
- event.preventDefault()
- event.stopPropagation()
- goBack()
+ // In a submenu: Esc and empty-input Backspace step back out
+ // instead of closing the whole palette.
+ if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {
+ event.preventDefault()
+ event.stopPropagation()
+ goBack()
- return
- }
- }}
- onValueChange={setSearch}
- placeholder={placeholder}
- right={page === 'pets' ? : undefined}
- value={search}
- />
-
- {/* Server-driven pages render their own list; the rest show groups. */}
- {page === 'pets' ? (
- {
- closeCommandPalette()
- openPetGenerate()
- }}
- search={search}
- />
- ) : page === 'install-theme' ? (
-
- ) : (
- <>
- {/* Filtering happens in rankGroups, so cmdk's own CommandEmpty
- (keyed to its internal filter count) would never fire. */}
- {visibleGroups.length === 0 && (
- {t.commandCenter.noResults}
- )}
- {visibleGroups.map((group, index) => (
-
- {group.items.map(item => (
-
- ))}
-
- ))}
- >
- )}
-
-
-
-
-
+ return
+ }
+ }}
+ onValueChange={setSearch}
+ placeholder={placeholder}
+ right={page === 'pets' ? : undefined}
+ value={search}
+ />
+
+ {/* Server-driven pages render their own list; the rest show groups. */}
+ {page === 'pets' ? (
+ {
+ closeCommandPalette()
+ openPetGenerate()
+ }}
+ search={search}
+ />
+ ) : page === 'install-theme' ? (
+
+ ) : (
+
+ )}
+
+
+
+
)
}
diff --git a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts
index d64a3a6fe79d..cff5d86f4d54 100644
--- a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts
+++ b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts
@@ -5,6 +5,7 @@ import { openSession } from '@/app/open-session'
import { storedSessionIdForNotification } from '@/lib/session-ids'
import { respondToApprovalAction } from '@/store/native-notifications'
import { $activeGatewayProfile } from '@/store/profile'
+import { openFolderAsProject } from '@/store/projects'
import {
$sessions,
getRememberedRoute,
@@ -187,6 +188,13 @@ export function useDesktopIntegrations({
return () => unsubscribe?.()
}, [navigate])
+ // File > Open Folder… — same open-folder-as-project upsert as the ⌘O keybind.
+ useEffect(() => {
+ const unsubscribe = window.hermesDesktop?.onOpenFolderRequested?.(() => void openFolderAsProject())
+
+ return () => unsubscribe?.()
+ }, [])
+
// Another window mutated the shared session list -> re-pull the sidebar.
useEffect(() => {
if (isSecondaryWindow()) {
diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx
index 559ccc90e0b1..44d35607e103 100644
--- a/apps/desktop/src/app/contrib/wiring.tsx
+++ b/apps/desktop/src/app/contrib/wiring.tsx
@@ -528,7 +528,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
}
lastStartWorkTokenRef.current = startWorkSessionRequest.token
- startSessionInWorkspace(startWorkSessionRequest.path)
+ startSessionInWorkspace(startWorkSessionRequest.path, { openTab: startWorkSessionRequest.openTab })
if (startWorkSessionRequest.draft) {
requestComposerInsert(startWorkSessionRequest.draft, { target: 'main' })
diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts
index 9012cc9c1109..1b4d535fa483 100644
--- a/apps/desktop/src/app/hooks/use-keybinds.ts
+++ b/apps/desktop/src/app/hooks/use-keybinds.ts
@@ -34,7 +34,7 @@ import {
switchToDefaultProfile,
toggleShowAllProfiles
} from '@/store/profile'
-import { requestNewWorktree } from '@/store/projects'
+import { openFolderAsProject, requestNewWorktree } from '@/store/projects'
import { toggleReview } from '@/store/review'
import { setModelPickerOpen } from '@/store/session'
import { reopenLastClosedTile } from '@/store/session-states'
@@ -174,6 +174,9 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
// Only meaningful inside a git repo — a no-op otherwise (the key falls
// through instead of silently doing nothing).
'workspace.newWorktree': () => $repoStatus.get() && requestNewWorktree(),
+ // ⌘O: native folder picker → open the folder as a project (upsert) with a
+ // fresh session anchored there.
+ 'workspace.openFolder': () => void openFolderAsProject(),
// Narrow-viewport reveal is handled inside the store toggles now.
'view.toggleSidebar': toggleSidebarOpen,
diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts
index 019328d21c5b..2c0913fb6dc1 100644
--- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts
+++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts
@@ -100,16 +100,8 @@ const _chatMessageFieldsExhaustive: {
[K in Exclude]: never
} = {}
-const COMPARED_FIELDS = [
- 'id',
- 'role',
- 'pending',
- 'error',
- 'hidden',
- 'branchGroupId',
- 'interim',
- 'reactions'
-] as const
+const COMPARED_FIELDS = ['id', 'role', 'pending', 'error', 'hidden', 'branchGroupId', 'interim', 'reactions'] as const
+
const IGNORED_FIELDS = ['timestamp', 'attachmentRefs', 'parts', 'rowId'] as const
// Compile-time check: every ChatMessagePart discriminant must be handled by
@@ -193,10 +185,7 @@ export function chatReactionsEquivalent(a: ChatMessage['reactions'], b: ChatMess
return (
aList.length === bList.length &&
- aList.every(
- (reaction, index) =>
- reaction.emoji === bList[index].emoji && reaction.author === bList[index].author
- )
+ aList.every((reaction, index) => reaction.emoji === bList[index].emoji && reaction.author === bList[index].author)
)
}
diff --git a/apps/desktop/src/app/settings/keybind-settings.tsx b/apps/desktop/src/app/settings/keybind-settings.tsx
index 0e6b69ee36a8..a974de48882e 100644
--- a/apps/desktop/src/app/settings/keybind-settings.tsx
+++ b/apps/desktop/src/app/settings/keybind-settings.tsx
@@ -173,11 +173,13 @@ export function KeybindSettings() {
function CategoryHeader({ label, onToggle, open }: { label: string; onToggle: () => void; open: boolean }) {
return (
- {label}
+
+ {label}
+
= ({ messageId, getMessageText,
const { t } = useI18n()
const copy = t.assistant.thread
- const reactions = useAuiState(s => {
- const custom = (s.message.metadata?.custom ?? {}) as { reactions?: MessageReaction[] }
-
- return custom.reactions ?? EMPTY_REACTIONS
- })
-
- const rowId = useAuiState(s => {
- const custom = (s.message.metadata?.custom ?? {}) as { rowId?: number }
-
- return custom.rowId
- })
-
const [pickerOpen, setPickerOpen] = useState(false)
- const reactionsEnabled = useStore($reactionsEnabled)
- const localAll = useStore($localReactions)
- const agentLive = useStore($agentReactions)
-
- const shownReactions = mergeReactions(
- reactions,
- localAll[messageId],
- rowId !== undefined ? agentLive[rowId] : undefined
- )
+ const { enabled: reactionsEnabled, react, reactions: shownReactions } = useMessageReactions(messageId, 'assistant')
- const react = useCallback(
+ const pickEmoji = useCallback(
(emoji: null | string) => {
setPickerOpen(false)
- // Flip the UI immediately — a tapback is direct manipulation and must
- // never wait on a round-trip. Persistence follows in the background.
- setLocalReaction(messageId, emoji)
- void toggleMessageReaction({ id: messageId, role: 'assistant', rowId, reactions } as ChatMessage, emoji)
+ react(emoji)
},
- [messageId, reactions, rowId]
+ [react]
)
return (
@@ -224,7 +199,7 @@ const AssistantActionBar: FC
= ({ messageId, getMessageText,
{(reactionsEnabled || shownReactions.length > 0) && (
reaction.author === 'user')?.emoji}
>
diff --git a/apps/desktop/src/components/assistant-ui/thread/double-click-reaction.test.tsx b/apps/desktop/src/components/assistant-ui/thread/double-click-reaction.test.tsx
new file mode 100644
index 000000000000..20ee08f1c615
--- /dev/null
+++ b/apps/desktop/src/components/assistant-ui/thread/double-click-reaction.test.tsx
@@ -0,0 +1,118 @@
+// Double-click an assistant reply to heart it (the iMessage gesture), gated on
+// the same opt-in toggle as the rest of message reactions.
+import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type * as ReactionsStore from '@/store/reactions'
+import { $reactionsEnabled } from '@/store/reactions-enabled'
+import { $localReactions } from '@/store/reactions-local'
+
+import { isTapbackDoubleClick } from './use-message-reactions'
+
+import { Thread } from '.'
+
+const createdAt = new Date('2026-05-01T00:00:00.000Z')
+
+class TestResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+vi.stubGlobal('ResizeObserver', TestResizeObserver)
+vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
+ window.setTimeout(() => callback(performance.now()), 0)
+)
+vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
+vi.stubGlobal('CSS', { escape: (str: string) => str })
+
+Element.prototype.scrollTo = function scrollTo() {}
+
+// The gesture persists through the gateway; this suite is about the local
+// paint, which is what the user actually sees on the click.
+vi.mock('@/store/reactions', async importOriginal => ({
+ ...(await importOriginal()),
+ toggleMessageReaction: vi.fn(async () => {})
+}))
+
+function assistantMessage(): ThreadMessage {
+ return {
+ id: 'assistant-1',
+ role: 'assistant',
+ content: [{ type: 'text', text: 'done' }],
+ status: { type: 'complete', reason: 'stop' },
+ createdAt,
+ metadata: { unstable_state: null, unstable_annotations: [], unstable_data: [], steps: [], custom: {} }
+ } as ThreadMessage
+}
+
+function Harness() {
+ const runtime = useExternalStoreRuntime({
+ messages: [assistantMessage()],
+ isRunning: false,
+ onNew: async () => {}
+ })
+
+ return (
+
+
+
+ )
+}
+
+beforeEach(() => {
+ $localReactions.set({})
+ $reactionsEnabled.set(false)
+})
+
+afterEach(() => {
+ cleanup()
+})
+
+describe('isTapbackDoubleClick', () => {
+ it('claims a plain double-click on message body', () => {
+ expect(isTapbackDoubleClick({ detail: 2, target: document.createElement('span') })).toBe(true)
+ })
+
+ it('ignores a triple-click, so selecting a paragraph does not re-toggle', () => {
+ expect(isTapbackDoubleClick({ detail: 3, target: document.createElement('span') })).toBe(false)
+ })
+
+ it('leaves double-click alone where it already means something', () => {
+ const code = document.createElement('pre')
+ const inner = document.createElement('code')
+
+ code.append(inner)
+
+ expect(isTapbackDoubleClick({ detail: 2, target: inner })).toBe(false)
+ expect(isTapbackDoubleClick({ detail: 2, target: document.createElement('a') })).toBe(false)
+ expect(isTapbackDoubleClick({ detail: 2, target: document.createElement('button') })).toBe(false)
+ })
+})
+
+describe('double-click to heart an assistant message', () => {
+ it('hearts the message, and a second double-click retracts it', async () => {
+ $reactionsEnabled.set(true)
+ render( )
+
+ const message = (await screen.findByText('done')).closest('[data-slot="aui_assistant-message-root"]')
+
+ expect(message).toBeTruthy()
+
+ fireEvent.doubleClick(message!, { detail: 2 })
+ await waitFor(() => expect($localReactions.get()['assistant-1']?.[0]?.emoji).toBe('❤️'))
+
+ fireEvent.doubleClick(message!, { detail: 2 })
+ await waitFor(() => expect($localReactions.get()['assistant-1']).toEqual([]))
+ })
+
+ it('does nothing while reactions are off', async () => {
+ render( )
+
+ const message = (await screen.findByText('done')).closest('[data-slot="aui_assistant-message-root"]')
+
+ fireEvent.doubleClick(message!, { detail: 2 })
+
+ expect($localReactions.get()['assistant-1']).toBeUndefined()
+ })
+})
diff --git a/apps/desktop/src/components/assistant-ui/thread/list.test.ts b/apps/desktop/src/components/assistant-ui/thread/list.test.ts
index e053af6f5ba9..a09d57deba4e 100644
--- a/apps/desktop/src/components/assistant-ui/thread/list.test.ts
+++ b/apps/desktop/src/components/assistant-ui/thread/list.test.ts
@@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest'
-import { buildGroups, firstVisibleGroupIndex, isVirtualizedGroup, LIVE_TAIL_GROUPS, type MessageGroup } from './list'
+import {
+ buildGroups,
+ firstVisibleGroupIndex,
+ LIVE_TAIL_MIN_GROUPS,
+ LIVE_TAIL_PARTS,
+ liveTailStart,
+ type MessageGroup
+} from './list'
// Signature rows are `${index}:${id}:${role}:${weight}` (see the useAuiState
// selector in list.tsx).
@@ -81,32 +88,79 @@ describe('firstVisibleGroupIndex', () => {
})
})
-describe('isVirtualizedGroup', () => {
- it('never virtualizes the newest turns (the live tail)', () => {
- const count = 20
+describe('liveTailStart', () => {
+ const group = (id: string, weight: number): MessageGroup => ({ id, index: 0, kind: 'standalone', weight })
- for (let i = count - LIVE_TAIL_GROUPS; i < count; i++) {
- expect(isVirtualizedGroup(i, count)).toBe(false)
- }
+ it('keeps the newest turns rendered until the parts budget is spent', () => {
+ // 10 turns x 10 parts. A 40-part tail covers the newest 4-5 turns.
+ const groups = Array.from({ length: 10 }, (_, i) => group(`g${i}`, 10))
+ const start = liveTailStart(groups)
+
+ expect(start).toBeGreaterThan(0)
+ expect(start).toBeLessThan(groups.length)
+
+ // Everything from `start` onward is the live tail...
+ const tailParts = groups.slice(start).reduce((sum, g) => sum + g.weight, 0)
+ expect(tailParts).toBeGreaterThan(LIVE_TAIL_PARTS)
+
+ // ...and dropping its oldest member puts it back under budget, i.e. the
+ // tail is minimal rather than sprawling.
+ const withoutOldest = groups.slice(start + 1).reduce((sum, g) => sum + g.weight, 0)
+ expect(withoutOldest).toBeLessThanOrEqual(LIVE_TAIL_PARTS)
})
- it('virtualizes older turns that sit before the live tail', () => {
- const count = 20
+ it('virtualizes the old bulk of a long agent transcript', () => {
+ // The regression this guards: heavy tool turns. A turn-count tail (6) left
+ // NOTHING virtualized on transcripts like this, so every Radix overlay open
+ // paid a whole-document style recalc.
+ const groups = Array.from({ length: 40 }, (_, i) => group(`g${i}`, 120))
- expect(isVirtualizedGroup(0, count)).toBe(true)
- expect(isVirtualizedGroup(count - LIVE_TAIL_GROUPS - 1, count)).toBe(true)
+ // Only the min-group floor stays rendered; the other 38 turns skip.
+ expect(liveTailStart(groups)).toBe(groups.length - LIVE_TAIL_MIN_GROUPS)
+ })
+
+ it('never virtualizes below the min-group floor, however heavy the turns', () => {
+ const groups = Array.from({ length: 5 }, (_, i) => group(`g${i}`, 10_000))
+
+ expect(liveTailStart(groups)).toBe(groups.length - LIVE_TAIL_MIN_GROUPS)
})
it('keeps every turn rendered when the whole transcript fits in the tail', () => {
- const count = LIVE_TAIL_GROUPS
+ const groups = [group('a', 5), group('b', 5), group('c', 5)]
- for (let i = 0; i < count; i++) {
- expect(isVirtualizedGroup(i, count)).toBe(false)
- }
+ expect(liveTailStart(groups)).toBe(0)
})
- it('honors a custom tail size', () => {
- expect(isVirtualizedGroup(5, 10, 3)).toBe(true)
- expect(isVirtualizedGroup(7, 10, 3)).toBe(false)
+ it('handles an empty transcript', () => {
+ expect(liveTailStart([])).toBe(0)
+ })
+
+ it('honors a custom budget', () => {
+ const groups = Array.from({ length: 10 }, (_, i) => group(`g${i}`, 1))
+
+ // A 3-part budget would keep 4 turns, but the max-groups ceiling is not hit
+ // here, so the parts budget wins.
+ expect(liveTailStart(groups, 3)).toBe(6)
+ })
+
+ it('never renders more than the old turn-count tail did, on any shape', () => {
+ // Guards the one way a parts budget can regress: a long transcript of tiny
+ // turns, where walking back 40 parts reaches further than 6 turns would.
+ const shapes = [
+ Array.from({ length: 40 }, () => 4), // long chat, tiny turns
+ Array.from({ length: 40 }, () => 1), // pathological: 1-part turns
+ Array.from({ length: 12 }, () => 6),
+ [80, 120, 60, 150, 90, 200, 70], // real agent tile
+ [30, 45]
+ ]
+
+ for (const weights of shapes) {
+ const groups = weights.map((weight, i) => group(`g${i}`, weight))
+ const rendered = (start: number) => weights.slice(start).reduce((a, b) => a + b, 0)
+
+ const oldStart = Math.max(0, groups.length - 6)
+
+ expect(rendered(liveTailStart(groups))).toBeLessThanOrEqual(rendered(oldStart))
+ }
})
})
diff --git a/apps/desktop/src/components/assistant-ui/thread/list.tsx b/apps/desktop/src/components/assistant-ui/thread/list.tsx
index 3afbc324c4f0..a9aff632e81b 100644
--- a/apps/desktop/src/components/assistant-ui/thread/list.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread/list.tsx
@@ -130,16 +130,63 @@ export function firstVisibleGroupIndex(groups: readonly MessageGroup[], budget:
// stick-to-bottom lock drifts and the view creeps up over older turns — the
// "long session eventually shows old responses" glitch.
//
-// Keep the newest N turns always-rendered so a turn is only ever virtualized
+// Keep the newest turns always-rendered so a turn is only ever virtualized
// once its layout has settled at its final size (remembered == real → skipping
// it changes no height). Off-screen OLDER turns still skip, so the dialog/popover
-// recalc win on long transcripts is preserved (that scales with the hundreds of
-// old turns, not this small live tail).
-export const LIVE_TAIL_GROUPS = 6
+// recalc win on long transcripts is preserved.
+//
+// The tail is budgeted in PARTS, not turns, because that is what the cost
+// actually scales with — the same currency as RENDER_BUDGET / FIRST_PAINT_BUDGET.
+// A turn-count tail silently defeats itself on agent transcripts: one tool-heavy
+// turn is 50-200 parts, so a 6-TURN tail exempted the entire visible transcript
+// and nothing virtualized at all. Measured on a 5-tile window (7/3/5/3/2 groups
+// per tile): zero content-visibility containers were active, and every Radix
+// overlay open paid the full ~610ms whole-document recalc that #66470 fixed.
+//
+// 40 parts ≈ the 1-2 turns a viewport shows after scroll-to-bottom (the same
+// reasoning as FIRST_PAINT_BUDGET=20, doubled so a turn that grows mid-stream
+// doesn't fall out of the tail as it settles).
+export const LIVE_TAIL_PARTS = 40
+// Floor: always exempt at least this many turns regardless of weight, so a
+// transcript of very heavy turns still keeps the streaming one unvirtualized.
+export const LIVE_TAIL_MIN_GROUPS = 2
+// Ceiling: never exempt more than this many turns, however light they are. On a
+// long transcript of tiny turns a parts-only budget would walk back further
+// than the old turn-count tail did and virtualize LESS — this keeps the new
+// policy a strict improvement on every shape.
+export const LIVE_TAIL_MAX_GROUPS = 6
+
+/**
+ * Index of the newest group that still virtualizes — everything at or after it
+ * is the live tail and stays rendered. Walks newest-first accumulating parts,
+ * so the tail covers a viewport's worth of content rather than a fixed number
+ * of turns, clamped to [MIN, MAX] turns. Computed once per render, not per row.
+ */
+export function liveTailStart(
+ groups: readonly MessageGroup[],
+ tailParts = LIVE_TAIL_PARTS,
+ minGroups = LIVE_TAIL_MIN_GROUPS,
+ maxGroups = LIVE_TAIL_MAX_GROUPS
+): number {
+ let parts = 0
+ let start = groups.length
+
+ for (let i = groups.length - 1; i >= 0; i--) {
+ parts += groups[i]?.weight ?? 1
+ start = i
+
+ if (parts > tailParts) {
+ break
+ }
+ }
-/** True when a visible group is old enough to virtualize (outside the live tail). */
-export function isVirtualizedGroup(indexInVisible: number, visibleCount: number, liveTail = LIVE_TAIL_GROUPS): boolean {
- return indexInVisible < visibleCount - liveTail
+ // Clamp the tail to [minGroups, maxGroups] turns: the floor keeps the live
+ // turn rendered when turns are huge, the ceiling stops a tail of tiny turns
+ // from sprawling past what the old turn-count policy rendered.
+ const floor = Math.max(0, groups.length - minGroups)
+ const ceiling = Math.max(0, groups.length - maxGroups)
+
+ return Math.min(floor, Math.max(ceiling, start))
}
const ThreadMessageListInner: FC = ({
@@ -278,6 +325,15 @@ const ThreadMessageListInner: FC = ({
const hiddenCount = firstVisibleGroupIndex(weightedGroups, renderBudget)
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
+
+ // Where the always-rendered live tail begins. Derived from the WEIGHTED
+ // groups (parts, not turns) so the tail is a viewport's worth of content —
+ // see liveTailStart. Computed once here rather than per row.
+ const tailStart = useMemo(
+ () => liveTailStart(hiddenCount > 0 ? weightedGroups.slice(hiddenCount) : weightedGroups),
+ [weightedGroups, hiddenCount]
+ )
+
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
// hide the titlebar tool cluster + session header, but the OS traffic lights
// still sit in the top-left, so reserve the titlebar gap above the transcript.
@@ -436,12 +492,11 @@ const ThreadMessageListInner: FC = ({
// The live tail (newest turns) is exempt: virtualizing a turn
// whose final size hasn't been remembered yet snaps it to a stale
// height when it scrolls off, drifting stick-to-bottom up over old
- // turns. See isVirtualizedGroup.
+ // turns. See liveTailStart.
@@ -461,7 +516,7 @@ const ThreadMessageListInner: FC = ({
)),
- [visibleGroups, components, structuralSignature]
+ [visibleGroups, components, structuralSignature, tailStart]
)
return (
diff --git a/apps/desktop/src/components/assistant-ui/thread/message-reactions.tsx b/apps/desktop/src/components/assistant-ui/thread/message-reactions.tsx
index 749335b1c382..05d07a5b12fe 100644
--- a/apps/desktop/src/components/assistant-ui/thread/message-reactions.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread/message-reactions.tsx
@@ -117,10 +117,7 @@ export const ReactionPicker: FC<{
// Opt this one surface out of the shared popover glass: emoji hover
// tints at 15% alpha are unreadable over blurred transcript text.
// Overriding the local surface var keeps the arrow matched for free.
- className={cn(
- 'w-auto p-1 [--popover-surface:var(--ui-bg-elevated)]',
- !expanded && 'flex gap-0.5'
- )}
+ className={cn('w-auto p-1 [--popover-surface:var(--ui-bg-elevated)]', !expanded && 'flex gap-0.5')}
onCloseAutoFocus={event => event.preventDefault()}
side="top"
>
diff --git a/apps/desktop/src/components/assistant-ui/thread/use-message-reactions.ts b/apps/desktop/src/components/assistant-ui/thread/use-message-reactions.ts
new file mode 100644
index 000000000000..75b011c55550
--- /dev/null
+++ b/apps/desktop/src/components/assistant-ui/thread/use-message-reactions.ts
@@ -0,0 +1,146 @@
+import { useAuiState, useMessageRuntime } from '@assistant-ui/react'
+import { useStore } from '@nanostores/react'
+import { type MouseEvent, useCallback } from 'react'
+
+import type { ChatMessage } from '@/lib/chat-messages'
+import { triggerHaptic } from '@/lib/haptics'
+import { QUICK_REACTIONS, toggleMessageReaction } from '@/store/reactions'
+import { $reactionsEnabled } from '@/store/reactions-enabled'
+import { $agentReactions, $localReactions, mergeReactions, setLocalReaction } from '@/store/reactions-local'
+import type { MessageReaction } from '@/types/hermes'
+
+// Stable empty identity — a fresh [] per render would re-run every consumer.
+const EMPTY_REACTIONS: MessageReaction[] = []
+
+/** The tapback a double-click lands: Apple's first Tapback, and ours. */
+export const DOUBLE_CLICK_REACTION = QUICK_REACTIONS[0]
+
+// Double-click means something else on these: links and controls act, inputs
+// and code blocks select. The gesture only claims plain message body.
+const NOT_A_TAPBACK = 'a, button, input, pre, select, textarea, [contenteditable="true"], [role="button"]'
+
+/**
+ * Is this double-click the "heart it" gesture?
+ *
+ * `detail === 2` keeps a triple-click (select-the-paragraph) from re-firing,
+ * and anything the browser already gives a double-click meaning keeps it.
+ */
+export function isTapbackDoubleClick(event: { detail: number; target: EventTarget | null }): boolean {
+ if (event.detail !== 2) {
+ return false
+ }
+
+ const target = event.target
+
+ return target instanceof Element ? !target.closest(NOT_A_TAPBACK) : true
+}
+
+/** Paint the tapback locally, then persist behind it. */
+function commitReaction(
+ messageId: string,
+ role: ChatMessage['role'],
+ rowId: number | undefined,
+ reactions: MessageReaction[],
+ emoji: null | string
+): void {
+ // Flip the UI immediately — a tapback is direct manipulation and must never
+ // wait on a round-trip. Persistence follows in the background.
+ setLocalReaction(messageId, emoji)
+ void toggleMessageReaction({ id: messageId, role, rowId, reactions } as ChatMessage, emoji)
+}
+
+/**
+ * A message's reactions and the one way to change them.
+ *
+ * Reads the durable list off `metadata.custom`, layers this window's live
+ * overlays on top (the user's own click, the agent's mid-turn event), and
+ * hands back a `react` that paints locally first and persists behind it.
+ * Shared by the assistant footer slot, the user bubble's picker, and the
+ * double-click gesture so all three apply identical tapback semantics.
+ */
+export function useMessageReactions(
+ messageId: string,
+ role: ChatMessage['role']
+): {
+ enabled: boolean
+ react: (emoji: null | string) => void
+ reactions: MessageReaction[]
+} {
+ const reactions = useAuiState(s => {
+ const custom = (s.message.metadata?.custom ?? {}) as { reactions?: MessageReaction[] }
+
+ return custom.reactions ?? EMPTY_REACTIONS
+ })
+
+ const rowId = useAuiState(s => {
+ const custom = (s.message.metadata?.custom ?? {}) as { rowId?: number }
+
+ return custom.rowId
+ })
+
+ const enabled = useStore($reactionsEnabled)
+ const localAll = useStore($localReactions)
+ const agentLive = useStore($agentReactions)
+
+ return {
+ enabled,
+ react: useCallback(
+ (emoji: null | string) => commitReaction(messageId, role, rowId, reactions, emoji),
+ [messageId, reactions, role, rowId]
+ ),
+ reactions: mergeReactions(reactions, localAll[messageId], rowId === undefined ? undefined : agentLive[rowId])
+ }
+}
+
+/**
+ * Double-click a message to heart it — the iMessage gesture.
+ *
+ * Reads the message's reaction state lazily at event time (the same trick the
+ * footer uses for its text): the gesture renders nothing, so subscribing the
+ * perf-sensitive message root to every reaction change would be pure cost.
+ * Returns `undefined` while reactions are off, so the element carries no
+ * listener at all.
+ */
+export function useTapbackDoubleClick(
+ messageId: string,
+ role: ChatMessage['role']
+): ((event: MouseEvent) => void) | undefined {
+ const enabled = useStore($reactionsEnabled)
+ const messageRuntime = useMessageRuntime()
+
+ const onDoubleClick = useCallback(
+ (event: MouseEvent) => {
+ if (!isTapbackDoubleClick(event)) {
+ return
+ }
+
+ // Double-click has already selected the word underneath — the tapback,
+ // not a stray selection, is what the gesture meant.
+ window.getSelection()?.removeAllRanges()
+ triggerHaptic('selection')
+
+ const custom = (messageRuntime.getState().metadata?.custom ?? {}) as {
+ reactions?: MessageReaction[]
+ rowId?: number
+ }
+
+ const reactions = custom.reactions ?? EMPTY_REACTIONS
+
+ // Same toggle semantics as the picker: a second double-click retracts.
+ const mine = mergeReactions(reactions, $localReactions.get()[messageId]).find(
+ reaction => reaction.author === 'user'
+ )
+
+ commitReaction(
+ messageId,
+ role,
+ custom.rowId,
+ reactions,
+ mine?.emoji === DOUBLE_CLICK_REACTION ? null : DOUBLE_CLICK_REACTION
+ )
+ },
+ [messageId, messageRuntime, role]
+ )
+
+ return enabled ? onDoubleClick : undefined
+}
diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx
index f27c5358af6c..fe7b917afbe9 100644
--- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx
@@ -168,7 +168,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess
const editor = editorRef.current
if (editor) {
- renderComposerContents(editor, next)
+ renderComposerContents(editor, next, { trailingCommitted: true })
placeCaretEnd(editor)
}
@@ -187,7 +187,11 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess
editor &&
(editor.childNodes.length === 0 || (document.activeElement !== editor && composerPlainText(editor) !== draft))
) {
- renderComposerContents(editor, draft)
+ // Inert by construction — this repaints on mount or when the editor
+ // isn't the one being typed into. A message opened for edit is finished
+ // text, so a `/command` ending it is committed and chips, matching how
+ // the transcript rendered that same message a moment ago.
+ renderComposerContents(editor, draft, { trailingCommitted: true })
if (document.activeElement === editor) {
placeCaretEnd(editor)
diff --git a/apps/desktop/src/components/assistant-ui/thread/user-message.tsx b/apps/desktop/src/components/assistant-ui/thread/user-message.tsx
index 00a024aa9749..16c6963e248c 100644
--- a/apps/desktop/src/components/assistant-ui/thread/user-message.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread/user-message.tsx
@@ -1,28 +1,20 @@
import { ActionBarPrimitive, BranchPickerPrimitive, MessagePrimitive, useAuiState } from '@assistant-ui/react'
-import { useStore } from '@nanostores/react'
import { type FC, type ReactNode, useCallback, useRef, useState } from 'react'
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
import { messageAttachmentRefs, messageContentText } from '@/components/assistant-ui/thread/content'
import { ReactionBadge, ReactionPicker } from '@/components/assistant-ui/thread/message-reactions'
import { type RestoreMessageTarget } from '@/components/assistant-ui/thread/types'
+import { useMessageReactions } from '@/components/assistant-ui/thread/use-message-reactions'
import { UserMessageText } from '@/components/assistant-ui/thread/user-message-text'
import { Codicon } from '@/components/ui/codicon'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
-import type { ChatMessage } from '@/lib/chat-messages'
import { triggerHaptic } from '@/lib/haptics'
import { StopFilled } from '@/lib/icons'
import { cn } from '@/lib/utils'
-import { toggleMessageReaction } from '@/store/reactions'
-import { $reactionsEnabled } from '@/store/reactions-enabled'
-import { $agentReactions, $localReactions, mergeReactions, setLocalReaction } from '@/store/reactions-local'
import { notifyThreadEditOpen } from '@/store/thread-scroll'
import { isWatchWindow } from '@/store/windows'
-import type { MessageReaction } from '@/types/hermes'
-
-// Stable empty identity — a fresh [] per render would re-run every consumer.
-const EMPTY_REACTIONS: MessageReaction[] = []
export function StickyHumanMessageContainer({
attachments,
@@ -154,38 +146,15 @@ export const UserMessage: FC<{
return messageAttachmentRefs(custom.attachmentRefs)
})
- const reactions = useAuiState(s => {
- const custom = (s.message.metadata?.custom ?? {}) as { reactions?: MessageReaction[] }
-
- return custom.reactions ?? EMPTY_REACTIONS
- })
-
- const rowId = useAuiState(s => {
- const custom = (s.message.metadata?.custom ?? {}) as { rowId?: number }
-
- return custom.rowId
- })
-
const [pickerOpen, setPickerOpen] = useState(false)
- const reactionsEnabled = useStore($reactionsEnabled)
- const localAll = useStore($localReactions)
- const agentLive = useStore($agentReactions)
-
- const shownReactions = mergeReactions(
- reactions,
- localAll[messageId],
- rowId !== undefined ? agentLive[rowId] : undefined
- )
+ const { enabled: reactionsEnabled, react, reactions: shownReactions } = useMessageReactions(messageId, 'user')
- const react = useCallback(
+ const pickEmoji = useCallback(
(emoji: null | string) => {
setPickerOpen(false)
- // Flip the UI immediately — a tapback is direct manipulation and must
- // never wait on a round-trip. Persistence follows in the background.
- setLocalReaction(messageId, emoji)
- void toggleMessageReaction({ id: messageId, role: 'user', rowId, reactions } as ChatMessage, emoji)
+ react(emoji)
},
- [messageId, reactions, rowId]
+ [react]
)
// Sticky human bubbles clamp to ~2 lines with a soft fade so a long prompt
@@ -303,7 +272,7 @@ export const UserMessage: FC<{
reaction.author === 'user')?.emoji}
>
diff --git a/apps/desktop/src/components/ui/disclosure-caret.tsx b/apps/desktop/src/components/ui/disclosure-caret.tsx
index 850ba4691905..24ac1e3ab29a 100644
--- a/apps/desktop/src/components/ui/disclosure-caret.tsx
+++ b/apps/desktop/src/components/ui/disclosure-caret.tsx
@@ -11,7 +11,7 @@ interface DisclosureCaretProps extends Omit {
export function DisclosureCaret({ className, open, size = '0.75rem', ...props }: DisclosureCaretProps) {
return (
Promise
}
onClosePreviewRequested?: (callback: () => void) => () => void
+ onOpenFolderRequested?: (callback: () => void) => () => void
onOpenUpdatesRequested?: (callback: () => void) => () => void
onDeepLink?: (
callback: (payload: { kind: string; name: string; params: Record }) => void
diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts
index f4727efef1d7..6e23f1b895d9 100644
--- a/apps/desktop/src/i18n/ar.ts
+++ b/apps/desktop/src/i18n/ar.ts
@@ -220,6 +220,7 @@ export const ar = defineLocale({
'session.focusSearch': 'البحث في الجلسات',
'session.togglePin': 'تثبيت / إلغاء تثبيت الجلسة الحالية',
'workspace.newWorktree': 'worktree جديد',
+ 'workspace.openFolder': 'فتح مجلد كمشروع',
'composer.focus': 'التركيز على المحرّر',
'composer.modelPicker': 'فتح منتقي النموذج',
'composer.voice': 'بدء / إيقاف المحادثة الصوتية',
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 2abf3a82c880..cf0575198d51 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -251,6 +251,7 @@ export const en: Translations = {
'session.focusSearch': 'Search sessions',
'session.togglePin': 'Pin / unpin current session',
'workspace.newWorktree': 'New worktree',
+ 'workspace.openFolder': 'Open folder as project',
'composer.focus': 'Focus composer',
'composer.modelPicker': 'Open model picker',
'composer.voice': 'Start / stop voice conversation',
@@ -1145,6 +1146,10 @@ export const en: Translations = {
goTo: 'Go to',
goToSession: 'Go to session',
branches: 'Branches',
+ projects: 'Projects',
+ openFolder: 'Open folder as project…',
+ openFolderAt: path => `Open folder as project — ${path}`,
+ newSessionInProject: project => `New session in ${project}`,
commands: 'Commands',
startInBranch: branch => `New conversation in ${branch}`,
commandCenter: 'Command Center',
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index d3d993d0d4aa..ea8ee8b5e858 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -1008,6 +1008,10 @@ export interface Translations {
goTo: string
goToSession: string
branches: string
+ projects: string
+ openFolder: string
+ openFolderAt: (path: string) => string
+ newSessionInProject: (project: string) => string
commands: string
startInBranch: (branch: string) => string
commandCenter: string
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 51b9f0710cf6..57140b2bdd28 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -246,6 +246,7 @@ export const zh: Translations = {
'session.focusSearch': '搜索会话',
'session.togglePin': '固定/取消固定当前会话',
'workspace.newWorktree': '新建工作树',
+ 'workspace.openFolder': '打开文件夹为项目',
'composer.focus': '聚焦输入框',
'composer.modelPicker': '打开模型选择器',
'composer.voice': '开始 / 停止语音对话',
@@ -1342,6 +1343,10 @@ export const zh: Translations = {
goTo: '前往',
goToSession: '前往会话',
branches: '分支',
+ projects: '项目',
+ openFolder: '打开文件夹为项目…',
+ openFolderAt: path => `打开文件夹为项目 — ${path}`,
+ newSessionInProject: project => `在 ${project} 中新建会话`,
commands: '命令',
startInBranch: branch => `在 ${branch} 中开始新对话`,
commandCenter: '命令中心',
diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts
index f4a609c202f6..392292bd7c2f 100644
--- a/apps/desktop/src/lib/keybinds/actions.ts
+++ b/apps/desktop/src/lib/keybinds/actions.ts
@@ -88,6 +88,11 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
{ id: 'session.togglePin', category: 'session', defaults: [] },
// ⌘⇧B — "b" for branch: spin up a new git worktree from the active repo.
{ id: 'workspace.newWorktree', category: 'session', defaults: ['mod+shift+b'] },
+ // ⌘O — the editor-standard "open folder" chord (VS Code ⌘O, Zed's
+ // workspace::Open). Picks a folder and opens it as a project (upsert:
+ // enters the owning project when one exists, else creates one), landing on
+ // a fresh session anchored there.
+ { id: 'workspace.openFolder', category: 'session', defaults: ['mod+o'] },
// ── Navigation ───────────────────────────────────────────────────────────
{ id: 'nav.commandPalette', category: 'navigation', defaults: ['mod+k', 'mod+p'] },
diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts
index 8b0f9cbc99ab..ca4511e6186c 100644
--- a/apps/desktop/src/store/projects.ts
+++ b/apps/desktop/src/store/projects.ts
@@ -161,6 +161,36 @@ export function exitProjectScope(): void {
$projectScope.set(ALL_PROJECTS)
}
+// A project's working root: its primary folder, else the first repo that has
+// one. Empty for the path-less Home bucket. (The sidebar's `projectTreeCwd` is
+// the same rule over the same tree — this is the store-side copy so the store
+// doesn't reach into the sidebar's React module.)
+const projectRootCwd = (project: SidebarProjectTree | undefined): string =>
+ (project?.path || project?.repos.find(repo => repo.path)?.path || '').trim()
+
+// ⌘K "go to project": flip the sidebar into grouped mode and enter the project
+// — a pure scope switch, same as clicking the overview row (never spends main).
+// With `newSession` (⌘-select / ⌘-Enter) it also lands on a fresh session draft
+// anchored at the project root — stacked as a tab when main already holds a
+// chat (palette opens are opens-from-nowhere). A path-less project (the Home
+// bucket) gets a plain detached draft.
+export function goToProject(id: string, options?: { newSession?: boolean }): void {
+ setSidebarAgentsGrouped(true)
+ enterProject(id)
+
+ if (!options?.newSession) {
+ return
+ }
+
+ const cwd = projectRootCwd($projectTree.get().find(node => node.id === id))
+
+ if (cwd) {
+ requestStartWorkSession(cwd, undefined, { openTab: true })
+ } else {
+ requestFreshSession()
+ }
+}
+
// The cwd a NEW chat should start in. The "active project" is just an atom
// ($projectScope) — so when you're inside a project, a new session (cmd-n, the
// trunk "+") starts at that project's root (its primary repo = the default-branch
@@ -177,8 +207,7 @@ export function resolveNewSessionCwd(): string {
}
if (scope !== ALL_PROJECTS) {
- const project = $projectTree.get().find(node => node.id === scope)
- const cwd = (project?.path || project?.repos.find(repo => repo.path)?.path || '').trim()
+ const cwd = projectRootCwd($projectTree.get().find(node => node.id === scope))
if (cwd) {
return cwd
@@ -997,6 +1026,8 @@ export async function switchBranchInRepo(repoPath: string, branch: string): Prom
// effect even if the path repeats.
export interface StartWorkSessionRequest {
draft?: string
+ /** Stack the fresh session as a tab when main already holds a chat (palette/⌘O opens-from-nowhere). */
+ openTab?: boolean
path: string
token: number
}
@@ -1016,7 +1047,7 @@ export function requestNewWorktree(): void {
let startWorkToken = 0
-export function requestStartWorkSession(path: string, draft?: string): void {
+export function requestStartWorkSession(path: string, draft?: string, options?: { openTab?: boolean }): void {
const target = path.trim()
if (!target) {
@@ -1024,7 +1055,12 @@ export function requestStartWorkSession(path: string, draft?: string): void {
}
startWorkToken += 1
- $startWorkSessionRequest.set({ draft: draft?.trim() || undefined, path: target, token: startWorkToken })
+ $startWorkSessionRequest.set({
+ draft: draft?.trim() || undefined,
+ openTab: options?.openTab || undefined,
+ path: target,
+ token: startWorkToken
+ })
}
export async function removeWorktreePath(
@@ -1068,3 +1104,48 @@ export async function pickProjectFolder(): Promise {
return dir || null
}
+
+// ⌘O / palette "Open folder…": open a folder AS a project, upserting. A folder
+// already covered by a project (explicit or auto) just enters it; anything else
+// becomes a new project named after the folder. Either way the sidebar scopes
+// to the project and a fresh session draft lands anchored at the folder — the
+// one-keystroke version of new project → enter → new session. Like goToProject,
+// this is an open-from-nowhere: an occupied main gets a stacked tab, not stolen.
+export async function openFolderAsProject(dir?: string): Promise {
+ const target = (dir ?? (await pickProjectFolder()) ?? '').trim()
+
+ if (!target) {
+ return
+ }
+
+ // Refresh first so the membership check runs against live truth — a repo
+ // cloned since the last scan should enter its auto project, not double-create.
+ await refreshProjectTree()
+
+ const existing = projectIdForCwd(target)
+
+ if (existing) {
+ setSidebarAgentsGrouped(true)
+ enterProject(existing)
+ } else {
+ const name =
+ target
+ .replace(/[/\\]+$/, '')
+ .split(/[/\\]/)
+ .pop() || target
+
+ try {
+ const created = await createProject({ name, folders: [target], primaryPath: target, use: true })
+
+ if (created) {
+ enterProject(created.id)
+ }
+ } catch (err) {
+ // Stale backend (no projects.* RPC) or a failed write: still open the
+ // folder as a plain workspace session below — the project row can wait.
+ notify({ kind: 'warning', message: err instanceof Error ? err.message : String(err) })
+ }
+ }
+
+ requestStartWorkSession(target, undefined, { openTab: true })
+}
diff --git a/apps/desktop/src/store/reactions.ts b/apps/desktop/src/store/reactions.ts
index c8c2b414a8a1..c75af2b81f1b 100644
--- a/apps/desktop/src/store/reactions.ts
+++ b/apps/desktop/src/store/reactions.ts
@@ -62,10 +62,7 @@ export async function toggleMessageReaction(
const gateway = activeGateway()
if (!sessionId || !gateway) {
- notifyError(
- new Error(!sessionId ? 'No active session' : 'Gateway not connected'),
- 'Could not react'
- )
+ notifyError(new Error(!sessionId ? 'No active session' : 'Gateway not connected'), 'Could not react')
return
}
diff --git a/apps/desktop/src/store/session-states.test.ts b/apps/desktop/src/store/session-states.test.ts
index 4133babb9276..3bff4ba52080 100644
--- a/apps/desktop/src/store/session-states.test.ts
+++ b/apps/desktop/src/store/session-states.test.ts
@@ -1,7 +1,7 @@
-import { describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
-import { group, split } from '@/components/pane-shell/tree/model'
+import { findGroupOfPane, group, split } from '@/components/pane-shell/tree/model'
import { $layoutTree } from '@/components/pane-shell/tree/store'
import { $selectedStoredSessionId } from '@/store/session'
import type { SessionTile } from '@/store/session-states'
@@ -138,3 +138,92 @@ describe('blankDraftTile', () => {
expect(blankDraftTile([], {})).toBeNull()
})
})
+
+// ⌘⇧T used to only restore `$sessionTiles`. Adoption inserts silently
+// (activate:false), so the tab came back behind the still-fronted workspace.
+// Real path: register, adopt, focus — same as paneMirror + reopen.
+describe('reopenLastClosedTile focuses the restored tab', () => {
+ beforeEach(() => {
+ window.localStorage.clear()
+ vi.resetModules()
+ })
+
+ afterEach(() => {
+ vi.resetModules()
+ })
+
+ async function setup() {
+ const tree = await import('@/components/pane-shell/tree/store')
+ const model = await import('@/components/pane-shell/tree/model')
+ const { registry } = await import('@/contrib/registry')
+ const session = await import('@/store/session')
+ const states = await import('@/store/session-states')
+
+ registry.register({
+ area: 'panes',
+ data: { placement: 'main', uncloseable: true },
+ id: 'workspace',
+ render: () => null,
+ title: 'chat'
+ })
+
+ // panes ← $sessionTiles (paneMirror stub). Adoption is synchronous on
+ // register, so openSessionTile + focusOpenSession works the same tick.
+ const registered = new Map void>()
+
+ const syncTiles = () => {
+ const wanted = new Set(states.$sessionTiles.get().map(t => t.storedSessionId))
+
+ for (const id of wanted) {
+ if (registered.has(id)) {
+ continue
+ }
+
+ registered.set(
+ id,
+ registry.register({
+ area: 'panes',
+ data: { dock: { pane: 'workspace', pos: 'center' }, placement: 'main' },
+ id: tilePane(id),
+ render: () => null,
+ title: id
+ })
+ )
+ }
+
+ for (const [id, dispose] of registered) {
+ if (!wanted.has(id)) {
+ dispose()
+ registered.delete(id)
+ tree.removeTreePane(tilePane(id))
+ }
+ }
+ }
+
+ states.$sessionTiles.listen(syncTiles)
+ tree.watchContributedPanes()
+ session.$selectedStoredSessionId.set('primary')
+ tree.declareDefaultTree(model.group(['workspace'], { active: 'workspace', id: 'grp-main' }))
+
+ states.openSessionTile('closed', 'center', 'workspace')
+ states.focusOpenSession('closed')
+ tree.noteActiveTreeGroup('grp-main')
+ expect(findGroupOfPane(tree.$layoutTree.get()!, tilePane('closed'))?.active).toBe(tilePane('closed'))
+
+ return { states, tree }
+ }
+
+ it('fronts the restored tab after ⌘⇧T', async () => {
+ const { states, tree } = await setup()
+
+ states.closeSessionTile('closed')
+ expect(states.$sessionTiles.get().some(t => t.storedSessionId === 'closed')).toBe(false)
+ expect(findGroupOfPane(tree.$layoutTree.get()!, 'workspace')?.active).toBe('workspace')
+
+ states.reopenLastClosedTile()
+
+ expect(states.$sessionTiles.get().some(t => t.storedSessionId === 'closed')).toBe(true)
+ expect(findGroupOfPane(tree.$layoutTree.get()!, tilePane('closed'))?.active).toBe(tilePane('closed'))
+ expect(tree.$activeTreeGroup.get()).toBe('grp-main')
+ })
+})
diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts
index 5d3f41c4fc6d..ebf58f9819c0 100644
--- a/apps/desktop/src/store/session-states.ts
+++ b/apps/desktop/src/store/session-states.ts
@@ -702,8 +702,10 @@ export function discardSessionTile(storedSessionId: string) {
saveTiles($sessionTiles.get().filter(t => t.storedSessionId !== storedSessionId))
}
-/** ⌘⇧T — reopen the most recently closed tab where it was. Skips ids that are
- * live again (reopened, or now the primary). */
+/** ⌘⇧T — reopen the most recently closed tab where it was, then focus it.
+ * Adoption alone is silent (won't steal the active tab), so restore has to
+ * front the pane explicitly. Skips ids that are live again (reopened / now
+ * the primary). */
export function reopenLastClosedTile(): void {
const stack = closedStack()
@@ -716,6 +718,7 @@ export function reopenLastClosedTile(): void {
if (!$sessionTiles.get().some(t => t.storedSessionId === storedSessionId)) {
openSessionTile(storedSessionId, tile.dir, tile.anchor, tile.before)
+ focusOpenSession(storedSessionId)
return
}
diff --git a/cli.py b/cli.py
index c6790fb1fe78..3fad1cc2616a 100644
--- a/cli.py
+++ b/cli.py
@@ -3235,10 +3235,12 @@ def _termux_example_image_path(filename: str = "cat.png") -> str:
"/storage/emulated/0",
"/storage/self/primary",
]
+ # Termux/Android roots are POSIX paths — join with literal forward
+ # slashes so the hint stays correct even when this renders on Windows.
for root in candidates:
if os.path.isdir(root):
- return os.path.join(root, "Pictures", filename)
- return os.path.join("~/storage/shared", "Pictures", filename)
+ return f"{root}/Pictures/{filename}"
+ return f"~/storage/shared/Pictures/{filename}"
def _split_path_input(raw: str) -> tuple[str, str]:
@@ -3309,6 +3311,16 @@ def _resolve_attachment_path(raw_path: str) -> Path | None:
expanded = unquote(parsed.path or "")
if parsed.netloc and os.name == "nt":
expanded = f"//{parsed.netloc}{expanded}"
+ elif (
+ os.name == "nt"
+ and len(expanded) >= 3
+ and expanded[0] == "/"
+ and expanded[1].isalpha()
+ and expanded[2] == ":"
+ ):
+ # file:///C:/... parses to path "/C:/..." — drop the
+ # leading slash so it resolves as a drive-letter path.
+ expanded = expanded[1:]
except Exception:
expanded = token
expanded = os.path.expandvars(os.path.expanduser(expanded))
diff --git a/gateway/kanban_watchers.py b/gateway/kanban_watchers.py
index 4b06cb970818..c786bed275ea 100644
--- a/gateway/kanban_watchers.py
+++ b/gateway/kanban_watchers.py
@@ -11,6 +11,7 @@
from __future__ import annotations
import asyncio
+import json
import logging
import os
import sqlite3
@@ -24,6 +25,64 @@
# "gateway.run") so extracted log records keep their original logger name.
logger = logging.getLogger("gateway.run")
+DISPATCHER_HEALTH_WINDOW = 6
+
+
+def _persist_dispatcher_health(
+ write_fn: Callable[[dict[str, Any]], None],
+ snapshot: dict[str, Any],
+) -> bool:
+ """Best-effort telemetry write; dispatch outcomes must never depend on it."""
+ try:
+ write_fn(snapshot)
+ except Exception:
+ logger.debug("kanban dispatcher: health persistence failed", exc_info=True)
+ return False
+ return True
+
+
+def _next_dispatcher_health(
+ previous_bad_ticks: int,
+ *,
+ any_spawned: bool,
+ capacity: dict[str, Any],
+ now: int,
+) -> tuple[int, dict[str, Any]]:
+ """Advance the sustained zero-spawn health window by one tick."""
+ probe_ok = bool(capacity.get("probe_ok", True))
+ dispatchable = int(capacity.get("dispatchable_count") or 0)
+ free_global_slots = capacity.get("free_global_slots")
+ if probe_ok and dispatchable > 0 and not any_spawned and free_global_slots != 0:
+ bad_ticks = previous_bad_ticks + 1
+ elif probe_ok:
+ bad_ticks = 0
+ else:
+ # Probe failure is not evidence of healthy idle capacity, and must not
+ # erase an already sustained condition.
+ bad_ticks = previous_bad_ticks
+ actionable = bad_ticks >= DISPATCHER_HEALTH_WINDOW
+ return bad_ticks, {
+ "schema_version": 1,
+ "updated_at": int(now),
+ "status": "actionable" if actionable else ("unavailable" if not probe_ok else "ok"),
+ "actionable": actionable,
+ "consecutive_zero_spawn_ticks": bad_ticks,
+ "health_window": DISPATCHER_HEALTH_WINDOW,
+ "dispatchable_count": dispatchable,
+ "free_global_slots": free_global_slots,
+ "running_count": int(capacity.get("running_count") or 0),
+ "boards": capacity.get("boards") or [],
+ "probe_ok": probe_ok,
+ "probe_errors": capacity.get("probe_errors") or [],
+ "degraded": not probe_ok,
+ "code": "dispatcher_zero_spawn_with_capacity" if actionable else None,
+ "recommended_action": (
+ "Check profile runtime health, PATH, credentials, and the ready queue."
+ if actionable
+ else None
+ ),
+ }
+
def _resolve_auto_decompose_settings(
load_config: Callable[[], Any],
@@ -1152,9 +1211,14 @@ async def _kanban_dispatcher_watcher(self) -> None:
# Health telemetry mirrored from `_cmd_daemon`: warn when ready
# queue is non-empty but spawns are 0 for N consecutive ticks —
# usually means broken PATH, missing venv, or credential loss.
- HEALTH_WINDOW = 6
+ HEALTH_WINDOW = DISPATCHER_HEALTH_WINDOW
bad_ticks = 0
last_warn_at = 0
+
+ def _persist_health(snapshot: dict[str, Any]) -> None:
+ """Persist health without allowing telemetry to stop dispatch."""
+ _persist_dispatcher_health(_kb.write_dispatcher_health, snapshot)
+
last_telemetry_review_boundary: int | None = None
# Avoid hot-looping corrupt-looking board DBs, but do not suppress
# same-fingerprint retries forever: transient WAL/open races can
@@ -1296,40 +1360,72 @@ def _tick_once() -> "list[tuple[str, Optional[object]]]":
out.append((slug, _tick_once_for_board(slug)))
return out
- def _ready_nonempty() -> bool:
- """Cheap probe: is there at least one ready+assigned+unclaimed
- task on ANY board whose assignee maps to a real Hermes profile
- (i.e. one the dispatcher would actually spawn for)?
-
- Tasks assigned to control-plane lanes (e.g. ``orion-cc``,
- ``orion-research``) are pulled by terminals via
- ``claim_task`` directly and never spawnable, so a queue full
- of those is "correctly idle", not "stuck". Filtering them out
- here keeps the stuck-warn fire only on real failures (broken
- PATH, missing venv, credential loss for a real Hermes profile).
+ def _dispatcher_capacity(
+ results: list[tuple[str, Optional[object]]],
+ *,
+ max_spawn: Optional[int],
+ max_in_progress: Optional[int],
+ max_in_progress_per_profile: Optional[int],
+ ) -> dict[str, Any]:
+ """Aggregate actionable dispatch capacity across all boards.
+
+ Probes every board unconditionally, regardless of whether that
+ board's dispatch tick just succeeded, failed, or was skipped
+ (e.g. quarantined corrupt DB) — mirrors the pre-refactor
+ ``_ready_nonempty()`` probe, which never gated its own connect
+ attempt on the dispatch tick's outcome. Health telemetry is
+ most valuable on exactly the boards where dispatch is failing,
+ so skipping the probe there would blind the signal it exists
+ to catch.
"""
- try:
- boards = _kb.list_boards(include_archived=False)
- except Exception:
- boards = [_kb.read_board_metadata(_kb.DEFAULT_BOARD)]
- for b in boards:
- slug = b.get("slug") or _kb.DEFAULT_BOARD
+ boards: list[dict[str, Any]] = []
+ total_dispatchable = 0
+ total_free_slots = 0
+ total_running = 0
+ unlimited_capacity = False
+ probe_errors: list[dict[str, str]] = []
+ for slug, _result in results or []:
conn = None
try:
conn = _kb.connect(board=slug)
- if _kb.has_spawnable_ready(conn):
- return True
- if _kb.has_spawnable_review(conn):
- return True
- except Exception:
- continue
+ snapshot = _kb.dispatcher_capacity_snapshot(
+ conn,
+ max_spawn=max_spawn,
+ max_in_progress=max_in_progress,
+ max_in_progress_per_profile=max_in_progress_per_profile,
+ )
+ boards.append({"slug": slug, **snapshot})
+ total_dispatchable += int(snapshot["dispatchable_count"])
+ total_running += int(snapshot["running_count"])
+ free = snapshot["free_global_slots"]
+ if free is None:
+ unlimited_capacity = True
+ else:
+ total_free_slots += int(free)
+ except Exception as exc:
+ probe_errors.append({"slug": slug, "error": type(exc).__name__})
+ logger.debug(
+ "kanban dispatcher: capacity probe failed on board %s",
+ slug,
+ exc_info=True,
+ )
finally:
if conn is not None:
try:
conn.close()
except Exception:
pass
- return False
+ return {
+ "dispatchable_count": total_dispatchable,
+ "free_global_slots": (
+ None if unlimited_capacity or not boards else total_free_slots
+ ),
+ "running_count": total_running,
+ "boards": boards,
+ "probe_ok": not probe_errors and bool(results),
+ "probe_errors": probe_errors,
+ "degraded": bool(probe_errors) or not results,
+ }
def _telemetry_review_tick() -> None:
"""Run one persisted review cycle for every active board."""
@@ -1492,12 +1588,24 @@ def _auto_decompose_tick(auto_decompose_per_tick: int) -> int:
res.promoted,
len(res.auto_blocked) if hasattr(res.auto_blocked, "__len__") else 0,
)
- # Health telemetry (aggregate across boards)
- ready_pending = await asyncio.to_thread(_ready_nonempty)
- if ready_pending and not any_spawned:
- bad_ticks += 1
- else:
- bad_ticks = 0
+ # Health telemetry (aggregate across boards). Only flag a
+ # sustained condition when there is real spawnable work AND
+ # there is global headroom. Nonspawnable assignees,
+ # active-PR guards, and per-profile caps are correctly idle.
+ capacity = await asyncio.to_thread(
+ _dispatcher_capacity,
+ results,
+ max_spawn=max_spawn,
+ max_in_progress=max_in_progress,
+ max_in_progress_per_profile=max_in_progress_per_profile,
+ )
+ bad_ticks, health_snapshot = _next_dispatcher_health(
+ bad_ticks,
+ any_spawned=any_spawned,
+ capacity=capacity,
+ now=int(time.time()),
+ )
+ _persist_health(health_snapshot)
if bad_ticks >= HEALTH_WINDOW:
now = int(time.time())
if now - last_warn_at >= 300:
diff --git a/gateway/run.py b/gateway/run.py
index d7f44c6a4b94..06a26d73aeab 100644
--- a/gateway/run.py
+++ b/gateway/run.py
@@ -18239,11 +18239,9 @@ def _should_send_voice_reply(
(voice_mode == "all")
or (voice_mode == "voice_only" and is_voice_input)
# ``voice.auto_tts`` is synced into the adapter on gateway startup.
- # Treat it as "voice accompanies text replies" unless a chat was
- # explicitly turned off. The base adapter's own auto-TTS path only
- # covers voice-input replies, so final text replies need the runner
- # path here.
- or (voice_mode != "off" and adapter_auto_tts)
+ # It is the fallback only when the chat has no explicit mode;
+ # otherwise the chat-level all/voice_only/off choice takes precedence.
+ or (voice_mode is None and adapter_auto_tts)
)
if not should:
logger.debug(
diff --git a/gateway/status.py b/gateway/status.py
index 5787a43821de..ce02648a958f 100644
--- a/gateway/status.py
+++ b/gateway/status.py
@@ -507,9 +507,12 @@ def _command_line_belongs_to_profile(command: str, profile_home: Path) -> bool:
explicit ``HERMES_HOME=``) on its argv; the default/root gateway runs
bare with no profile flag.
"""
- command_lc = command.lower()
+ # Normalize separators before the substring match: on Windows,
+ # str(Path) renders backslashes while a HERMES_HOME= value on the argv
+ # may carry forward slashes (Git Bash, JSON configs) — and vice versa.
+ command_lc = command.lower().replace("\\", "/")
profile_name = _profile_name_for_home(profile_home)
- home_lc = str(profile_home).lower()
+ home_lc = str(profile_home).lower().replace("\\", "/")
if profile_name is not None and profile_name != "default":
profile_lc = profile_name.lower()
diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py
index 4d2b077ec964..7811899aeb3f 100644
--- a/hermes_cli/banner.py
+++ b/hermes_cli/banner.py
@@ -40,7 +40,14 @@ def cprint(text: str):
"""Print ANSI-colored text through prompt_toolkit's renderer."""
from prompt_toolkit import print_formatted_text as _pt_print
from prompt_toolkit.formatted_text import ANSI as _PT_ANSI
- _pt_print(_PT_ANSI(text))
+ try:
+ _pt_print(_PT_ANSI(text))
+ except Exception:
+ # prompt_toolkit needs a real console. On Windows, a redirected or
+ # absent stdout (pythonw.exe, CI, `hermes ... > file`) raises
+ # NoConsoleScreenBufferError from its Win32Output — display helpers
+ # must never crash the caller over that, so degrade to plain print.
+ print(text)
# =========================================================================
diff --git a/hermes_cli/browser_connect.py b/hermes_cli/browser_connect.py
index 4fcc4cc63c5f..af1b04eaee0a 100644
--- a/hermes_cli/browser_connect.py
+++ b/hermes_cli/browser_connect.py
@@ -5,6 +5,7 @@
import logging
import os
import platform
+import posixpath
import shlex
import shutil
import subprocess
@@ -95,7 +96,10 @@ def add_windows_install_paths(
for _, group in install_groups:
for base in filter(None, bases):
for parts in group:
- add(os.path.join(base, *parts))
+ # Only called with WSL ``/mnt/c/...`` bases — those are
+ # POSIX paths regardless of the host OS, so join with
+ # posixpath (os.path.join would emit backslashes on nt).
+ add(posixpath.join(base, *parts))
if system == "Darwin":
for app in _DARWIN_APPS:
diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py
index 067b2c3209b9..55b8a196f14c 100644
--- a/hermes_cli/gateway.py
+++ b/hermes_cli/gateway.py
@@ -359,7 +359,9 @@ def _scan_gateway_pids(
looks_like_gateway_runtime_command_line,
)
current_home = str(get_hermes_home().resolve())
- current_home_lc = current_home.lower()
+ # Forward slashes on both sides of the HERMES_HOME= match — see
+ # gateway.status._command_line_belongs_to_profile, which this mirrors.
+ current_home_lc = current_home.lower().replace("\\", "/")
current_profile_arg = _profile_arg(current_home)
current_profile_name = (
current_profile_arg.split()[-1] if current_profile_arg else ""
@@ -367,7 +369,7 @@ def _scan_gateway_pids(
current_profile_name_lc = current_profile_name.lower()
def _matches_current_profile(command: str) -> bool:
- command_lc = command.lower()
+ command_lc = command.lower().replace("\\", "/")
if current_profile_name:
return (
f"--profile {current_profile_name_lc}" in command_lc
diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py
index d4fff424aa86..689a4278399b 100644
--- a/hermes_cli/kanban.py
+++ b/hermes_cli/kanban.py
@@ -2652,6 +2652,12 @@ def _coerce_positive_int(value):
for (tid, who, current) in res.skipped_per_profile_capped
],
"auto_assigned_default": res.auto_assigned_default,
+ "respawn_guarded": [
+ {"task_id": tid, "reason": reason}
+ for (tid, reason) in res.respawn_guarded
+ ],
+ "rate_limited": res.rate_limited,
+ "skipped_locked": res.skipped_locked,
}, indent=2))
return 0
print(f"Reclaimed: {res.reclaimed}")
@@ -2689,6 +2695,16 @@ def _coerce_positive_int(value):
f"Dispatch failed (missing assignee profile; create it or reassign): "
f"{', '.join(res.skipped_nonspawnable)}"
)
+ if res.respawn_guarded:
+ for tid, reason in res.respawn_guarded:
+ print(f"Respawn guarded ({reason}): {tid}")
+ if res.rate_limited:
+ print(
+ f"Rate-limited / billing wall (requeued, no failure counted): "
+ f"{', '.join(res.rate_limited)}"
+ )
+ if res.skipped_locked:
+ print("Tick skipped: another dispatcher holds the board lock.")
return 0
diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py
index df32877052d5..975619a338ba 100644
--- a/hermes_cli/kanban_db.py
+++ b/hermes_cli/kanban_db.py
@@ -660,6 +660,39 @@ def current_board_path() -> Path:
return kanban_home() / "kanban" / "current"
+def dispatcher_health_path() -> Path:
+ """Return the machine-readable embedded-dispatcher health file path."""
+ return kanban_home() / "kanban" / "dispatcher-health.json"
+
+
+def read_dispatcher_health() -> Optional[dict[str, Any]]:
+ """Read the last persisted dispatcher health snapshot, if valid."""
+ path = dispatcher_health_path()
+ try:
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError, UnicodeError):
+ return None
+ return payload if isinstance(payload, dict) else None
+
+
+def write_dispatcher_health(payload: Mapping[str, Any]) -> None:
+ """Atomically persist the embedded dispatcher's latest health snapshot."""
+ path = dispatcher_health_path()
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
+ try:
+ tmp.write_text(
+ json.dumps(dict(payload), sort_keys=True, separators=(",", ":")) + "\n",
+ encoding="utf-8",
+ )
+ os.replace(tmp, path)
+ finally:
+ try:
+ tmp.unlink(missing_ok=True)
+ except OSError:
+ pass
+
+
def get_current_board() -> str:
"""Return the active board slug, honouring the resolution chain.
@@ -8177,6 +8210,25 @@ def _resolve_worktree_workspace(
return requested, branch_name
+def _is_os_agnostic_absolute(raw: str) -> bool:
+ """Return True if ``raw`` is an absolute path under *any* mainstream OS.
+
+ ``pathlib.Path.is_absolute()`` is OS-dependent: a POSIX-style path like
+ ``/Users/dan/repo`` (no drive letter) is absolute on POSIX but NOT on
+ Windows, where ``WindowsPath('/Users/...').is_absolute()`` returns False.
+ In a mixed-host dispatch fleet (Mac authors the task, a Windows
+ control-plane host resolves the workspace) that mismatch raised a false
+ "non-absolute workspace_path" error and killed every dir: task on spawn.
+
+ We accept a path as absolute if EITHER interpretation says so:
+ POSIX (leading ``/``) or Windows (drive-letter / UNC). This validates the
+ author's intent regardless of which OS runs the resolver.
+ """
+ from pathlib import PurePosixPath, PureWindowsPath
+
+ return PurePosixPath(raw).is_absolute() or PureWindowsPath(raw).is_absolute()
+
+
def resolve_workspace(task: Task, *, board: Optional[str] = None) -> Path:
"""Resolve (and create if needed) the workspace for a task.
@@ -8210,7 +8262,7 @@ def resolve_workspace(task: Task, *, board: Optional[str] = None) -> Path:
# same absolute-path guard as dir: — consistent with the
# threat model.
p = Path(task.workspace_path).expanduser()
- if not p.is_absolute():
+ if not _is_os_agnostic_absolute(task.workspace_path):
raise ValueError(
f"task {task.id} has non-absolute workspace_path "
f"{task.workspace_path!r}; workspace paths must be absolute"
@@ -8225,7 +8277,7 @@ def resolve_workspace(task: Task, *, board: Optional[str] = None) -> Path:
f"task {task.id} has workspace_kind=dir but no workspace_path"
)
p = Path(task.workspace_path).expanduser()
- if not p.is_absolute():
+ if not _is_os_agnostic_absolute(task.workspace_path):
raise ValueError(
f"task {task.id} has non-absolute workspace_path "
f"{task.workspace_path!r}; use an absolute path "
@@ -8352,7 +8404,7 @@ def schedule_task(
DEFAULT_RATE_LIMIT_COOLDOWN_SECONDS = 300 # 5 minutes
# Within this window a GitHub PR URL in a comment blocks re-spawn.
-_RESPAWN_GUARD_PR_WINDOW = 86400 # 24 hours
+_RESPAWN_GUARD_PR_WINDOW = 3600 # 1 hour (HAA 2026-07-29 option B: the prior 24h window suppressed 12/16 ready tasks because merge-lane work posts PR URLs constantly; combined with the code-task scoping in check_respawn_guard this keeps duplicate-PR protection without starving the board)
# Pattern matching a GitHub PR URL in task comments.
_RESPAWN_GUARD_PR_URL_RE = re.compile(
@@ -8461,6 +8513,94 @@ def _record_worker_exit(pid: int, raw_status: int) -> None:
_recent_worker_exits.pop(_pid, None)
+# ---------------------------------------------------------------------------
+# Worker-log startup/billing failure signatures (truthful spawn telemetry)
+# ---------------------------------------------------------------------------
+#
+# When a worker dies at startup (bad skill pin, missing profile) or bails on
+# a provider billing wall, the OS-level exit status alone produces opaque
+# noise ("pid exited with code 1", or worse, a clean rc=0 that gets
+# mis-scored as a protocol violation). These signatures let
+# ``detect_crashed_workers`` read the tail of the per-task worker log and
+# classify the failure loudly + actionably instead.
+#
+# Each entry: (error_code, compiled_pattern, message_template).
+# ``{match}`` in the template is replaced with the first matching log line.
+_WORKER_LOG_FAILURE_SIGNATURES: "list[tuple[str, re.Pattern, str]]" = [
+ (
+ "billing_exhausted",
+ re.compile(
+ r"(?:HTTP[\s_-]*)?\b402\b"
+ r"|insufficient(?:[\s_]available)?[\s_]credits"
+ r"|insufficient_quota"
+ r"|exceeded your current quota"
+ r"|billing hard limit",
+ re.IGNORECASE,
+ ),
+ "provider credits exhausted (billing wall): {match}. "
+ "Requeued without counting a failure — add credits or remap the "
+ "assignee profile to a model with balance; the respawn guard will "
+ "pace retries until then.",
+ ),
+ (
+ "unknown_skill",
+ re.compile(r"Unknown skill\(s\)[^\n]*", re.IGNORECASE),
+ "worker startup failed: {match}. The task pins skill(s) that are not "
+ "installed on the assignee profile — install the skill on that "
+ "profile, or re-create the task without the bad skill pin.",
+ ),
+ (
+ "missing_profile",
+ re.compile(r"Profile '[^']+' does not exist[^\n]*"),
+ "worker startup failed: {match}. Create that Hermes profile or "
+ "reassign the task to an installed profile.",
+ ),
+]
+
+# How much of the worker log tail to scan for failure signatures. Startup
+# failures print within the first/last couple hundred bytes; 8 KiB is a
+# comfortable margin without paging megabytes on every dead-pid check.
+_WORKER_LOG_CLASSIFY_TAIL_BYTES = 8192
+
+
+def _classify_worker_failure_from_log(
+ task_id: str, *, board: Optional[str] = None,
+ min_mtime: Optional[float] = None,
+) -> "Optional[tuple[str, str]]":
+ """Scan the tail of a dead worker's log for a known failure signature.
+
+ Returns ``(error_code, detail_message)`` for the first matching
+ signature, or ``None`` when the log is missing/unreadable or matches
+ nothing. Never raises — this is best-effort enrichment; the caller
+ falls back to the OS-level exit classification.
+
+ ``min_mtime`` guards against stale evidence: worker logs are opened in
+ append mode across runs, so the tail can still contain a PRIOR run's
+ failure lines. When set, a log whose mtime predates ``min_mtime``
+ (typically the current run's ``started_at``) is ignored — nothing was
+ written during this run, so its tail proves nothing about this death.
+ """
+ try:
+ path = worker_log_path(task_id, board=board)
+ if not path.exists():
+ return None
+ if min_mtime is not None and path.stat().st_mtime < float(min_mtime):
+ return None
+ tail = read_worker_log(
+ task_id, tail_bytes=_WORKER_LOG_CLASSIFY_TAIL_BYTES, board=board,
+ )
+ except Exception:
+ return None
+ if not tail:
+ return None
+ for code, pattern, template in _WORKER_LOG_FAILURE_SIGNATURES:
+ m = pattern.search(tail)
+ if m:
+ match_line = m.group(0).strip()[:200]
+ return (code, template.format(match=match_line))
+ return None
+
+
def _classify_worker_exit(pid: int) -> "tuple[str, Optional[int]]":
"""Classify a recently-reaped worker by pid.
@@ -9141,7 +9281,9 @@ def _protocol_violation_streak(conn: sqlite3.Connection, task_id: str) -> int:
return streak
-def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]:
+def detect_crashed_workers(
+ conn: sqlite3.Connection, *, board: Optional[str] = None,
+) -> list[str]:
"""Reclaim ``running`` tasks whose worker PID is no longer alive.
Appends a ``crashed`` event and drops the task back to ``ready``.
@@ -9205,9 +9347,44 @@ def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]:
pid = int(row["worker_pid"])
kind, code = _classify_worker_exit(pid)
+ # Enrich the OS-level exit status with evidence from the worker
+ # log tail: a 402/billing wall, a bad skill pin, or a missing
+ # profile all leave a distinctive line in the log while the exit
+ # status alone is opaque (rc=1, or a clean rc=0 that would be
+ # mis-scored as a protocol violation — incident 2026-07-30
+ # t_543dce5d: 12 consecutive 402s recorded as violations).
+ log_class = _classify_worker_failure_from_log(
+ row["id"], board=board,
+ min_mtime=(
+ float(started_at) if started_at is not None else None
+ ),
+ )
+ log_error_code = log_class[0] if log_class else None
+ log_error_detail = log_class[1] if log_class else None
rate_limited_exit = False
force_block = False
- if kind == "clean_exit":
+ if log_error_code == "billing_exhausted":
+ # Billing/credit exhaustion is a provider wall, not a task
+ # failure — same disposition as the EX_TEMPFAIL sentinel:
+ # requeue WITHOUT counting a failure so the breaker can't
+ # trip, and stamp a quota-flavored error so
+ # ``check_respawn_guard`` paces retries. This applies even
+ # when the worker exited rc=0 (it never got a model
+ # response, so there was nothing to complete or block).
+ protocol_violation = False
+ rate_limited_exit = True
+ error_text = (
+ f"pid {pid} died on a provider billing wall — "
+ f"{log_error_detail}"
+ )
+ event_kind = "rate_limited"
+ event_payload = {
+ "pid": pid,
+ "claimer": row["claim_lock"],
+ "exit_code": code,
+ "error_code": "billing_exhausted",
+ }
+ elif kind == "clean_exit":
# Worker subprocess returned 0 but its task is still
# ``running`` in the DB — it exited without calling
# ``kanban_complete`` / ``kanban_block``. Overwhelmingly the
@@ -9316,6 +9493,16 @@ def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]:
event_payload["signal"] = (
int(code) - KANBAN_FORCED_SIGNAL_EXIT_BASE
)
+ # Loud classification for startup failures: a bad skill pin
+ # or a missing assignee profile crashes the worker pre-flight
+ # with an otherwise-opaque "pid exited with code 1". Attach
+ # the actionable detail from the worker log so the run row,
+ # events, and retry-worker context all say what actually
+ # broke and what to do about it.
+ if log_error_code in ("unknown_skill", "missing_profile"):
+ error_text = f"{error_text}: {log_error_detail}"
+ event_payload["error_code"] = log_error_code
+ event_payload["error_detail"] = log_error_detail
cur = conn.execute(
"UPDATE tasks SET status = 'ready', claim_lock = NULL, "
@@ -9799,6 +9986,9 @@ def check_respawn_guard(conn: sqlite3.Connection, task_id: str) -> Optional[str]
A GitHub PR URL appears in a recent task comment (within
``_RESPAWN_GUARD_PR_WINDOW`` seconds). A prior worker already
opened a PR; re-spawning risks a duplicate PR on the same task.
+ Bypassed when an explicit re-queue event arrives after the latest
+ matching PR comment, because that event deliberately requests more
+ work even though the task already has PR evidence.
Stale / dead claim locks are NOT a guard reason — they are handled
by ``release_stale_claims`` and ``detect_crashed_workers`` which
@@ -9881,12 +10071,46 @@ def check_respawn_guard(conn: sqlite3.Connection, task_id: str) -> Optional[str]
return "recent_success"
# 4. GitHub PR URL in a recent comment — prior worker already opened a PR.
+ # Restrict this to code/PR-producing tasks. Evidence-reconciliation and
+ # research tasks commonly cite PR URLs but use a dir workspace with no
+ # branch; suppressing those tasks creates a false duplicate-PR signal.
+ task_shape = conn.execute(
+ "SELECT workspace_kind, branch_name FROM tasks WHERE id = ?",
+ (task_id,),
+ ).fetchone()
+ code_task = bool(
+ task_shape
+ and (
+ task_shape["workspace_kind"] == "worktree"
+ or (task_shape["branch_name"] or "").strip()
+ )
+ )
+ if not code_task:
+ return None
+ # As with recent_success, an explicit re-queue AFTER the latest matching
+ # PR comment is a deliberate request to continue work. A re-queue before
+ # that comment does not bypass the guard: the newer PR evidence wins.
pr_cutoff = now - _RESPAWN_GUARD_PR_WINDOW
+ latest_pr_comment_at = None
for c in conn.execute(
- "SELECT body FROM task_comments WHERE task_id = ? AND created_at >= ?",
+ "SELECT body, created_at FROM task_comments "
+ "WHERE task_id = ? AND created_at >= ? "
+ "ORDER BY created_at DESC, id DESC",
(task_id, pr_cutoff),
).fetchall():
if c["body"] and _RESPAWN_GUARD_PR_URL_RE.search(c["body"]):
+ latest_pr_comment_at = int(c["created_at"])
+ break
+
+ if latest_pr_comment_at is not None:
+ requeued_after = conn.execute(
+ "SELECT 1 FROM task_events "
+ "WHERE task_id = ? AND created_at > ? "
+ "AND kind IN ('status', 'promoted', 'unblocked', 'reclaimed') "
+ "LIMIT 1",
+ (task_id, latest_pr_comment_at),
+ ).fetchone()
+ if not requeued_after:
return "active_pr"
return None
@@ -9949,6 +10173,86 @@ def has_spawnable_review(conn: sqlite3.Connection) -> bool:
return False
+def dispatcher_capacity_snapshot(
+ conn: sqlite3.Connection,
+ *,
+ max_spawn: Optional[int] = None,
+ max_in_progress: Optional[int] = None,
+ max_in_progress_per_profile: Optional[int] = None,
+) -> dict[str, Any]:
+ """Describe spawnable work while honoring dispatcher guardrails."""
+ running_count = int(
+ conn.execute("SELECT COUNT(*) FROM tasks WHERE status = 'running'").fetchone()[0]
+ )
+ caps = [
+ cap for cap in (max_spawn, max_in_progress)
+ if isinstance(cap, int) and cap > 0
+ ]
+ global_cap = min(caps) if caps else None
+ free_global_slots = (
+ None if global_cap is None else max(0, global_cap - running_count)
+ )
+ snapshot: dict[str, Any] = {
+ "running_count": running_count,
+ "global_cap": global_cap,
+ "free_global_slots": free_global_slots,
+ "dispatchable_count": 0,
+ "dispatchable_task_ids": [],
+ }
+ if free_global_slots == 0:
+ return snapshot
+
+ try:
+ from hermes_cli.profiles import profile_exists
+ except Exception:
+ profile_exists = None # type: ignore[assignment]
+ per_profile_cap = (
+ max_in_progress_per_profile
+ if isinstance(max_in_progress_per_profile, int)
+ and max_in_progress_per_profile > 0
+ else None
+ )
+ per_profile_running: dict[str, int] = {}
+ if per_profile_cap is not None:
+ per_profile_running = {
+ row["assignee"]: int(row["n"])
+ for row in conn.execute(
+ "SELECT assignee, COUNT(*) AS n FROM tasks "
+ "WHERE status = 'running' AND assignee IS NOT NULL "
+ "GROUP BY assignee"
+ )
+ }
+
+ candidates: list[str] = []
+ rows = conn.execute(
+ "SELECT id, status, assignee FROM tasks "
+ "WHERE status IN ('ready', 'review') AND assignee IS NOT NULL "
+ "AND claim_lock IS NULL "
+ "ORDER BY CASE status WHEN 'ready' THEN 0 ELSE 1 END, "
+ "priority DESC, created_at ASC"
+ ).fetchall()
+ for row in rows:
+ assignee = row["assignee"]
+ if profile_exists is not None and not profile_exists(assignee):
+ continue
+ if (
+ per_profile_cap is not None
+ and per_profile_running.get(assignee, 0) >= per_profile_cap
+ ):
+ continue
+ if row["status"] == "ready":
+ if check_respawn_guard(conn, row["id"]) is not None:
+ continue
+ candidates.append(row["id"])
+ if per_profile_cap is not None:
+ per_profile_running[assignee] = per_profile_running.get(assignee, 0) + 1
+ if free_global_slots is not None:
+ candidates = candidates[:free_global_slots]
+ snapshot["dispatchable_count"] = len(candidates)
+ snapshot["dispatchable_task_ids"] = candidates
+ return snapshot
+
+
def dispatch_once(
conn: sqlite3.Connection,
*,
@@ -10070,7 +10374,7 @@ def _dispatch_once_locked(
result.stale = detect_stale_running(
conn, stale_timeout_seconds=stale_timeout_seconds,
)
- result.crashed = detect_crashed_workers(conn)
+ result.crashed = detect_crashed_workers(conn, board=board)
# detect_crashed_workers stashes protocol-violation auto-blocks on
# itself so the public list-return stays stable. Pull them into the
# DispatchResult here so telemetry / tests see the trip.
@@ -10447,6 +10751,8 @@ def validate_pre_dispatch(task: Task) -> bool:
for row in review_rows:
if max_spawn is not None and running_count + spawned >= max_spawn:
break
+ if max_in_progress is not None and running_count + spawned >= max_in_progress:
+ break
if not row["assignee"]:
result.skipped_unassigned.append(row["id"])
continue
@@ -10476,8 +10782,19 @@ def validate_pre_dispatch(task: Task) -> bool:
candidate = get_task(conn, row["id"])
if candidate is None or not validate_pre_dispatch(candidate):
continue
+ if _per_profile_cap is not None:
+ current = _per_profile_running.get(row["assignee"], 0)
+ if current >= _per_profile_cap:
+ result.skipped_per_profile_capped.append(
+ (row["id"], row["assignee"], current)
+ )
+ continue
if dry_run:
result.spawned.append((row["id"], row["assignee"], ""))
+ if _per_profile_cap is not None:
+ _per_profile_running[row["assignee"]] = (
+ _per_profile_running.get(row["assignee"], 0) + 1
+ )
continue
claimed = claim_review_task(conn, row["id"], ttl_seconds=ttl_seconds)
if claimed is None:
@@ -10537,6 +10854,10 @@ def validate_pre_dispatch(task: Task) -> bool:
_set_worker_pid(conn, claimed.id, int(pid))
result.spawned.append((claimed.id, claimed.assignee or "", str(workspace)))
spawned += 1
+ if _per_profile_cap is not None and claimed.assignee:
+ _per_profile_running[claimed.assignee] = (
+ _per_profile_running.get(claimed.assignee, 0) + 1
+ )
except Exception as exc:
auto = _record_spawn_failure(
conn, claimed.id, str(exc),
diff --git a/hermes_cli/update_lock.py b/hermes_cli/update_lock.py
index cfd6a4ea697e..2d8b2aaf8aca 100644
--- a/hermes_cli/update_lock.py
+++ b/hermes_cli/update_lock.py
@@ -27,6 +27,15 @@
than :data:`UPDATE_MARKER_MAX_AGE_MS` — mirroring ``readLiveUpdateMarker`` so a
crashed updater self-heals instead of wedging every future update. A stale
marker is removed on read by whoever notices it first.
+
+One layering wrinkle: the Tauri updater holds this marker for its WHOLE run and
+then spawns ``hermes update`` as a child stage. Without a handoff the child
+sees its own parent's live marker and refuses — the GUI update deadlocks
+against itself on every attempt ("Hermes is still running", retry forever).
+The updater therefore exports :data:`HANDOFF_PID_ENV` naming its own pid, and
+``acquire`` treats a live holder matching that pid as the lock we are already
+running under. The env var alone grants nothing: the pid must also be the
+live marker owner, so a stale or forged value cannot bypass the lock.
"""
from __future__ import annotations
@@ -47,6 +56,13 @@
MARKER_NAME = ".hermes-update-in-progress"
+# Set by an orchestrating updater (the Tauri `hermes-setup --update` flow) to
+# its own pid before spawning `hermes update` as a child stage. The parent
+# holds the marker for its whole run, so without this the child refuses its
+# own parent's lock and the GUI update can never complete. See update_child_env
+# in apps/bootstrap-installer/src-tauri/src/update.rs — keep the name in sync.
+HANDOFF_PID_ENV = "HERMES_UPDATE_HANDOFF_PID"
+
# Exit code meaning "another updater/instance owns this install right now".
# Already the de-facto contract: the Windows shim + venv-holder guards in
# _cmd_update_impl exit 2, and the Tauri updater matches on it
@@ -95,6 +111,22 @@ def _pid_alive(pid: int) -> bool:
return False
+def _handoff_pid() -> int | None:
+ """Pid of the orchestrating updater that spawned us, if any.
+
+ Read from :data:`HANDOFF_PID_ENV`. Malformed values count as absent —
+ a broken handoff must fall back to the normal refusal, never crash.
+ """
+ raw = os.environ.get(HANDOFF_PID_ENV, "").strip()
+ if not raw:
+ return None
+ try:
+ pid = int(raw)
+ except ValueError:
+ return None
+ return pid if pid > 0 else None
+
+
@dataclass(frozen=True)
class UpdateHolder:
"""A confirmed-live update currently holding the lock."""
@@ -168,9 +200,17 @@ def __init__(self, *, path: Path | None = None) -> None:
self.holder: UpdateHolder | None = None
def acquire(self) -> bool:
- """Claim the lock. Returns False (and sets ``holder``) if it's taken."""
+ """Claim the lock. Returns False (and sets ``holder``) if it's taken.
+
+ A live holder whose pid matches :data:`HANDOFF_PID_ENV` is our own
+ orchestrating parent (the Tauri updater spawning `hermes update` as a
+ stage): we run under ITS claim rather than refusing or re-writing the
+ marker, and ``release`` leaves the parent's marker untouched.
+ """
existing = read_live_update(path=self.path)
if existing is not None:
+ if existing.pid == _handoff_pid():
+ return True
self.holder = existing
return False
try:
diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py
index b7b1f381bdb6..d5ad2768381d 100644
--- a/plugins/kanban/dashboard/plugin_api.py
+++ b/plugins/kanban/dashboard/plugin_api.py
@@ -55,6 +55,8 @@
router = APIRouter()
+_DISPATCHER_HEALTH_STALE_AFTER_SECONDS = 600
+
# ---------------------------------------------------------------------------
# Auth helper — WebSocket only (HTTP routes live behind the dashboard's
@@ -1317,6 +1319,34 @@ def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)):
# the rule engine.
# ---------------------------------------------------------------------------
+@router.get("/dispatcher/health")
+def get_dispatcher_health():
+ """Return the latest machine-readable embedded-dispatcher health signal."""
+ checked_at = int(time.time())
+ signal = kanban_db.read_dispatcher_health()
+ if signal is None:
+ return {
+ "available": False,
+ "stale": True,
+ "checked_at": checked_at,
+ "signal": None,
+ }
+ updated_at = signal.get("updated_at")
+ try:
+ age_seconds = max(0, checked_at - int(str(updated_at)))
+ except (TypeError, ValueError):
+ age_seconds = None
+ stale = age_seconds is None or age_seconds > _DISPATCHER_HEALTH_STALE_AFTER_SECONDS
+ degraded = bool(signal.get("degraded") or signal.get("status") == "unavailable")
+ return {
+ "available": not degraded,
+ "stale": stale,
+ "age_seconds": age_seconds,
+ "checked_at": checked_at,
+ "signal": signal,
+ }
+
+
@router.get("/diagnostics")
def list_diagnostics(
board: Optional[str] = Query(None, description="Kanban board slug (omit for current)"),
diff --git a/skills/autonomous-ai-agents/hermes-agent/references/contributor-guide.md b/skills/autonomous-ai-agents/hermes-agent/references/contributor-guide.md
index 3bda90211855..a578f06d25c1 100644
--- a/skills/autonomous-ai-agents/hermes-agent/references/contributor-guide.md
+++ b/skills/autonomous-ai-agents/hermes-agent/references/contributor-guide.md
@@ -84,8 +84,9 @@ run_conversation():
### Testing
-Use the canonical runner — it enforces CI-parity (hermetic env, unset
-credentials, TZ=UTC, xdist workers, per-test subprocess isolation):
+Use the canonical runner — it enforces CI-parity (hermetic `env -i`, unset
+credentials, TZ=UTC, per-file subprocess isolation via
+`scripts/run_tests_parallel.py` — no xdist, worker count auto-scaled):
```bash
scripts/run_tests.sh # full suite
@@ -102,7 +103,7 @@ scripts/run_tests.sh -v --tb=long # pass-through pytest flags
**Cross-platform test guards:** tests using POSIX-only syscalls need a skip marker. Common ones already in the codebase:
- Symlink creation → `@pytest.mark.skipif(sys.platform == "win32", reason="Symlinks require elevated privileges on Windows")` (see `tests/cron/test_cron_script.py`)
- POSIX file modes (0o600, etc.) → `@pytest.mark.skipif(sys.platform.startswith("win"), reason="POSIX mode bits not enforced on Windows")` (see `tests/hermes_cli/test_auth_toctou_file_modes.py`)
-- `signal.SIGALRM` → Unix-only (see `tests/conftest.py::_enforce_test_timeout`)
+- `signal.SIGALRM` → Unix-only (per-test timeouts no longer use it directly; see the win32 timeout-method shim in `tests/conftest.py::pytest_configure`)
- Live Winsock / Windows-specific regression tests → `@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific regression")`
**Monkeypatching `sys.platform` is not enough** when the code under test also calls `platform.system()` / `platform.release()` / `platform.mac_ver()`. Those functions re-read the real OS independently, so a test that sets `sys.platform = "linux"` on a Windows runner will still see `platform.system() == "Windows"` and route through the Windows branch. Patch all three together:
diff --git a/skills/autonomous-ai-agents/hermes-agent/references/windows-quirks.md b/skills/autonomous-ai-agents/hermes-agent/references/windows-quirks.md
index 4cf283e95332..d87f1c0eb5b2 100644
--- a/skills/autonomous-ai-agents/hermes-agent/references/windows-quirks.md
+++ b/skills/autonomous-ai-agents/hermes-agent/references/windows-quirks.md
@@ -33,13 +33,14 @@ echo `os.environ` inside an `execute_code` block to confirm `SYSTEMROOT` is set.
`scripts/run_tests.sh` is POSIX-only (expects `.venv/bin/activate`); the
Hermes-installed `venv/Scripts/` has no pip/pytest (stripped for size).
-Install pytest into a system Python and run directly with `-n 0`
-(`pyproject.toml`'s `addopts` already sets `-n`):
+Install pytest into a system Python and run directly (the repo no longer
+uses pytest-xdist; the canonical runner does per-file subprocess isolation,
+which the POSIX-only wrapper handles):
```bash
-"/c/Program Files/Python311/python" -m pip install --user pytest pytest-xdist pyyaml
+"/c/Program Files/Python311/python" -m pip install --user pytest pyyaml
export PYTHONPATH="$(pwd)"
-"/c/Program Files/Python311/python" -m pytest tests/foo/test_bar.py -v --tb=short -n 0
+"/c/Program Files/Python311/python" -m pytest tests/foo/test_bar.py -v --tb=short
```
(POSIX-only tests need skip guards — see the cross-platform guard list in
diff --git a/skills/creative/comfyui/tests/README.md b/skills/creative/comfyui/tests/README.md
index 833632ae9c41..d27fa97e32aa 100644
--- a/skills/creative/comfyui/tests/README.md
+++ b/skills/creative/comfyui/tests/README.md
@@ -43,8 +43,10 @@ When you change a script:
## Why the explicit `-c` / `-o`?
-The parent hermes-agent repo's `pyproject.toml` enables `pytest-xdist` by
-default (`-n auto`). This suite is small enough that parallelism isn't
-worth the complexity, and pytest-xdist isn't always installed in the user's
-environment. The `-c tests/pytest.ini -o addopts="-p no:xdist"` flags make
-the suite run identically regardless of the parent project's config.
+The parent hermes-agent repo used to enable `pytest-xdist` by default
+(`-n auto`); the canonical runner has since moved to per-file subprocess
+isolation via `scripts/run_tests_parallel.py` and no longer uses xdist.
+This suite is small enough that parallelism isn't worth the complexity, and
+pytest-xdist isn't always installed in the user's environment. The
+`-c tests/pytest.ini -o addopts="-p no:xdist"` flags make the suite run
+identically regardless of the parent project's config.
diff --git a/skills/software-development/python-debugpy/SKILL.md b/skills/software-development/python-debugpy/SKILL.md
index e57d8d91e253..9907afdac1d2 100644
--- a/skills/software-development/python-debugpy/SKILL.md
+++ b/skills/software-development/python-debugpy/SKILL.md
@@ -107,11 +107,9 @@ scripts/run_tests.sh tests/path/to/test_file.py::test_name --trace
scripts/run_tests.sh tests/path/to/test_file.py --showlocals --tb=long
```
-Note: `scripts/run_tests.sh` uses xdist (`-n 4`) by default, and pdb does NOT work under xdist. Add `-p no:xdist` or run a single test with `-n 0`:
+Note: `scripts/run_tests.sh` runs each test file in a captured subprocess via `run_tests_parallel.py` (no xdist), so interactive pdb does NOT work under the wrapper. Run pytest directly for `--pdb`:
```bash
-scripts/run_tests.sh tests/foo_test.py::test_bar --pdb -p no:xdist
-# or
source .venv/bin/activate
python -m pytest tests/foo_test.py::test_bar --pdb
```
@@ -276,7 +274,7 @@ nc 127.0.0.1 4444
## Debugging Hermes-specific Processes
### Tests
-See Recipe 3. Always add `-p no:xdist` or run single tests without xdist.
+See Recipe 3. The wrapper captures subprocess output, so run pytest directly for interactive pdb.
### `run_agent.py` / CLI — one-shot
Easiest: add `breakpoint()` near the suspect line, then run `hermes` normally. Control returns to your terminal at the pause point.
@@ -308,7 +306,7 @@ Long-lived. Use `remote-pdb` at a handler, or `debugpy` with `--wait-for-client`
## Common Pitfalls
-1. **pdb under pytest-xdist silently does nothing.** You won't see the prompt, the test just hangs. Always use `-p no:xdist` or `-n 0`.
+1. **pdb under a parallel/output-capturing runner silently does nothing.** You won't see the prompt, the test just hangs (true of pytest-xdist and of `scripts/run_tests.sh`'s captured per-file subprocesses). Run pytest directly on a single file for interactive debugging.
2. **`breakpoint()` in CI / non-TTY contexts hangs the process.** Safe locally; never commit it. Add a pre-commit grep as a safety net.
@@ -333,7 +331,7 @@ Long-lived. Use `remote-pdb` at a handler, or `debugpy` with `--wait-for-client`
- [ ] After `pip install debugpy`, confirm: `python -c "import debugpy; print(debugpy.__version__)"`
- [ ] For remote debug, confirm the port is actually listening: `ss -tlnp | grep 5678`
-- [ ] First breakpoint actually hits (if it doesn't, you likely have `PYTHONBREAKPOINT=0`, you're under xdist, or execution finished before attach)
+- [ ] First breakpoint actually hits (if it doesn't, you likely have `PYTHONBREAKPOINT=0`, you're under a parallel/capturing runner, or execution finished before attach)
- [ ] `where` / `w` shows the expected call stack
- [ ] Post-debug cleanup: no stray `breakpoint()` / `set_trace()` in committed code
```bash
@@ -354,10 +352,10 @@ breakpoint()
**"This test passes in isolation but fails in the suite."**
```bash
-scripts/run_tests.sh tests/the_test.py --pdb -p no:xdist
-# But if it only fails WITH other tests:
+scripts/run_tests.sh tests/the_test.py # confirm it fails under the isolated runner first
+# For interactive debugging, or if it only fails WITH other tests:
source .venv/bin/activate
-python -m pytest tests/ -x --pdb -p no:xdist
+python -m pytest tests/ -x --pdb
# Now it pdb-traps at the exact failing test after state accumulated.
```
diff --git a/tests/cli/test_cli_browser_connect.py b/tests/cli/test_cli_browser_connect.py
index 2f17b0595a45..4bdc56cbcf5f 100644
--- a/tests/cli/test_cli_browser_connect.py
+++ b/tests/cli/test_cli_browser_connect.py
@@ -83,6 +83,17 @@ def test_linux_candidates_include_official_brave_and_edge_stable_paths(self):
assert candidates == [brave, edge]
+ def test_wsl_install_candidates_keep_posix_separators_on_nt_host(self):
+ expected = "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe"
+
+ with patch("hermes_cli.browser_connect.shutil.which", return_value=None), \
+ patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == expected):
+ candidates = get_chrome_debug_candidates("Linux")
+
+ assert candidates == [expected]
+ assert "\\" not in candidates[0]
+
+
def test_wait_for_browser_debug_ready_or_exit_detects_early_exit(self, monkeypatch):
class _Proc:
def __init__(self):
diff --git a/tests/cli/test_cli_file_drop.py b/tests/cli/test_cli_file_drop.py
index b6093e215605..00c0fd5d5f8c 100644
--- a/tests/cli/test_cli_file_drop.py
+++ b/tests/cli/test_cli_file_drop.py
@@ -1,6 +1,7 @@
"""Tests for _detect_file_drop — file path detection that prevents
dragged/pasted absolute paths from being mistaken for slash commands."""
+import os
import pytest
@@ -157,6 +158,8 @@ def test_tilde_prefixed_path(self, tmp_path, monkeypatch):
img.parent.mkdir(parents=True, exist_ok=True)
img.write_bytes(b"\x89PNG\r\n\x1a\n")
monkeypatch.setenv("HOME", str(home))
+ # ntpath.expanduser ignores HOME (Python 3.8+) — it wants USERPROFILE.
+ monkeypatch.setenv("USERPROFILE", str(home))
result = _detect_file_drop("~/storage/shared/Pictures/cat.png what is this?")
@@ -166,6 +169,19 @@ def test_tilde_prefixed_path(self, tmp_path, monkeypatch):
assert result["remainder"] == "what is this?"
+ @pytest.mark.skipif(os.name != "nt", reason="Windows drive-letter URI contract")
+ def test_windows_drive_letter_file_uri_drops_url_leading_slash(self, tmp_path):
+ image = tmp_path / "drive-uri.png"
+ image.write_bytes(b"\x89PNG\r\n\x1a\n")
+ uri = image.as_uri()
+ assert uri.startswith("file:///") and ":/" in uri
+
+ result = _detect_file_drop(uri)
+
+ assert result is not None
+ assert result["path"] == image
+
+
# ---------------------------------------------------------------------------
# Tests: edge cases
# ---------------------------------------------------------------------------
diff --git a/tests/cli/test_cli_image_command.py b/tests/cli/test_cli_image_command.py
index 0af4635dfa90..573efbe77e9c 100644
--- a/tests/cli/test_cli_image_command.py
+++ b/tests/cli/test_cli_image_command.py
@@ -59,6 +59,8 @@ def test_collect_query_images_supports_tilde_paths(self, tmp_path, monkeypatch):
home = tmp_path / "home"
img = _make_image(home / "storage" / "shared" / "Pictures" / "cat.png")
monkeypatch.setenv("HOME", str(home))
+ # ntpath.expanduser ignores HOME (Python 3.8+) — it wants USERPROFILE.
+ monkeypatch.setenv("USERPROFILE", str(home))
message, images = _collect_query_images("describe this", "~/storage/shared/Pictures/cat.png")
diff --git a/tests/cli/test_worktree.py b/tests/cli/test_worktree.py
index 6ca7c4514cc1..626ca29bd4a6 100644
--- a/tests/cli/test_worktree.py
+++ b/tests/cli/test_worktree.py
@@ -416,6 +416,21 @@ def test_ten_concurrent_worktrees(self, git_repo):
assert not Path(info["path"]).exists()
+def _can_symlink():
+ """Check if we can create symlinks (needs admin/dev-mode on Windows)."""
+ import tempfile
+ try:
+ with tempfile.TemporaryDirectory() as d:
+ src = Path(d) / "src"
+ src.write_text("x")
+ lnk = Path(d) / "lnk"
+ lnk.symlink_to(src)
+ return True
+ except OSError:
+ return False
+
+
+@pytest.mark.skipif(not _can_symlink(), reason="Symlinks need elevated privileges")
class TestWorktreeDirectorySymlink:
"""Test .worktreeinclude with directories (symlinked)."""
diff --git a/tests/cli/test_worktree_security.py b/tests/cli/test_worktree_security.py
index bd5aae81cd20..c8c2b89f20a1 100644
--- a/tests/cli/test_worktree_security.py
+++ b/tests/cli/test_worktree_security.py
@@ -6,6 +6,20 @@
import pytest
+def _can_symlink():
+ """Check if we can create symlinks (needs admin/dev-mode on Windows)."""
+ import tempfile
+ try:
+ with tempfile.TemporaryDirectory() as d:
+ src = Path(d) / "src"
+ src.write_text("x")
+ lnk = Path(d) / "lnk"
+ lnk.symlink_to(src)
+ return True
+ except OSError:
+ return False
+
+
@pytest.fixture
def git_repo(tmp_path):
"""Create a temporary git repo for testing real cli._setup_worktree behavior."""
@@ -76,6 +90,7 @@ def test_rejects_parent_directory_directory_traversal(self, git_repo):
finally:
_force_remove_worktree(info)
+ @pytest.mark.skipif(not _can_symlink(), reason="Symlinks need elevated privileges")
def test_rejects_symlink_that_resolves_outside_repo(self, git_repo):
import cli as cli_mod
@@ -110,6 +125,7 @@ def test_allows_valid_file_include(self, git_repo):
finally:
_force_remove_worktree(info)
+ @pytest.mark.skipif(not _can_symlink(), reason="Symlinks need elevated privileges")
def test_allows_valid_directory_include(self, git_repo):
import cli as cli_mod
diff --git a/tests/gateway/test_auto_voice_reply_format.py b/tests/gateway/test_auto_voice_reply_format.py
index 16fdab8ef6ed..824556ba21e3 100644
--- a/tests/gateway/test_auto_voice_reply_format.py
+++ b/tests/gateway/test_auto_voice_reply_format.py
@@ -73,6 +73,25 @@ def test_should_send_voice_reply_streamed_global_auto_tts_fires(self):
voice_event, "hello", [], already_sent=True
) is True
+ def test_should_send_voice_reply_voice_only_still_requires_voice_input(self):
+ """Explicit voice_only must not widen to text input (#73508 regression).
+
+ Persisted voice_only mode is synced into the adapter as an explicit
+ auto-TTS opt-in, so adapter_auto_tts is True for this chat. The
+ chat-level mode stays authoritative: text input gets no voice reply,
+ voice input still does.
+ """
+ runner = _make_runner()
+ runner._voice_mode["telegram:123"] = "voice_only"
+ adapter = _make_adapter(Platform.TELEGRAM)
+ adapter._should_auto_tts_for_chat = MagicMock(return_value=True)
+ runner.adapters[Platform.TELEGRAM] = adapter
+ event = _make_event(Platform.TELEGRAM, chat_id="123")
+
+ assert runner._should_send_voice_reply(event, "hello", []) is False
+
+ voice_event = _make_event(Platform.TELEGRAM, chat_id="123", message_type=MessageType.VOICE)
+ assert runner._should_send_voice_reply(voice_event, "hello", [], already_sent=True) is True
def _make_runner() -> GatewayRunner:
with patch("gateway.run.GatewayRunner._load_voice_modes", return_value={}):
diff --git a/tests/gateway/test_kanban_watchers_mixin.py b/tests/gateway/test_kanban_watchers_mixin.py
index 7a67ac58dbdc..9a18f69a21a1 100644
--- a/tests/gateway/test_kanban_watchers_mixin.py
+++ b/tests/gateway/test_kanban_watchers_mixin.py
@@ -9,9 +9,16 @@
import inspect
from datetime import datetime
+from pathlib import Path
from zoneinfo import ZoneInfo
-from gateway.kanban_watchers import GatewayKanbanWatchersMixin, _telemetry_review_due
+from gateway.kanban_watchers import (
+ DISPATCHER_HEALTH_WINDOW,
+ GatewayKanbanWatchersMixin,
+ _next_dispatcher_health,
+ _persist_dispatcher_health,
+ _telemetry_review_due,
+)
KANBAN_METHODS = [
"_kanban_notifier_watcher",
@@ -28,6 +35,136 @@ def test_mixin_defines_kanban_methods():
assert hasattr(GatewayKanbanWatchersMixin, m), f"mixin missing {m}"
+def test_gateway_runner_inherits_mixin():
+ # Import here so a heavy gateway import only happens if the first test passed.
+ from gateway.run import GatewayRunner
+
+ assert issubclass(GatewayRunner, GatewayKanbanWatchersMixin)
+ # Each kanban method resolves to the mixin's implementation via the MRO.
+ for m in KANBAN_METHODS:
+ owner = next(c for c in GatewayRunner.__mro__ if m in c.__dict__)
+ assert owner is GatewayKanbanWatchersMixin, (
+ f"{m} resolved to {owner.__name__}, expected the mixin"
+ )
+
+
+def test_watcher_loops_are_coroutines():
+ # The two long-running watchers are async loops.
+ assert inspect.iscoroutinefunction(GatewayKanbanWatchersMixin._kanban_notifier_watcher)
+ assert inspect.iscoroutinefunction(GatewayKanbanWatchersMixin._kanban_dispatcher_watcher)
+
+
+def test_singleton_dispatcher_lock_is_exclusive(tmp_path):
+ """Only one holder of the dispatcher lock at a time — the backstop that
+ stops concurrent dispatchers double reclaiming and corrupting shared
+ kanban SQLite index pages under wal_autocheckpoint=0."""
+ import os
+
+ from gateway.kanban_watchers import _acquire_singleton_lock, _release_singleton_lock
+
+ lock = tmp_path / "kanban" / ".dispatcher.lock"
+
+ h1, st1 = _acquire_singleton_lock(lock)
+ assert st1 == "held" and h1 is not None
+
+ # A second acquire while the first is held must be refused, not granted.
+ h2, st2 = _acquire_singleton_lock(lock)
+ assert st2 == "contended" and h2 is None
+
+ # Releasing the first lets a fresh acquire succeed (lock is reusable).
+ _release_singleton_lock(h1)
+ h3, st3 = _acquire_singleton_lock(lock)
+ assert st3 == "held" and h3 is not None
+ _release_singleton_lock(h3)
+
+
+def test_dispatcher_health_becomes_actionable_after_six_zero_spawn_ticks():
+ ticks = 0
+ signal = {}
+ capacity = {
+ "dispatchable_count": 2,
+ "free_global_slots": 3,
+ "running_count": 1,
+ "boards": [{"slug": "default", "dispatchable_count": 2}],
+ }
+
+ for now in range(DISPATCHER_HEALTH_WINDOW):
+ ticks, signal = _next_dispatcher_health(
+ ticks, any_spawned=False, capacity=capacity, now=now,
+ )
+
+ assert signal["actionable"] is True
+ assert signal["status"] == "actionable"
+ assert signal["code"] == "dispatcher_zero_spawn_with_capacity"
+ assert signal["consecutive_zero_spawn_ticks"] == DISPATCHER_HEALTH_WINDOW
+ assert signal["recommended_action"]
+
+
+def test_dispatcher_health_resets_for_correctly_idle_ticks():
+ cases = [
+ ({"dispatchable_count": 0, "free_global_slots": 3}, False),
+ ({"dispatchable_count": 2, "free_global_slots": 0}, False),
+ ({"dispatchable_count": 2, "free_global_slots": 3}, True),
+ ]
+ for capacity, any_spawned in cases:
+ ticks, signal = _next_dispatcher_health(
+ DISPATCHER_HEALTH_WINDOW - 1,
+ any_spawned=any_spawned,
+ capacity=capacity,
+ now=100,
+ )
+ assert ticks == 0
+ assert signal["actionable"] is False
+
+
+def test_dispatcher_health_probe_failure_is_unavailable_and_preserves_window():
+ ticks, signal = _next_dispatcher_health(
+ DISPATCHER_HEALTH_WINDOW - 1,
+ any_spawned=False,
+ capacity={
+ "probe_ok": False,
+ "probe_errors": [{"slug": "broken", "error": "DatabaseError"}],
+ "dispatchable_count": 0,
+ "free_global_slots": None,
+ },
+ now=100,
+ )
+
+ assert ticks == DISPATCHER_HEALTH_WINDOW - 1
+ assert signal["status"] == "unavailable"
+ assert signal["degraded"] is True
+ assert signal["probe_ok"] is False
+ assert signal["probe_errors"][0]["slug"] == "broken"
+
+
+def test_health_persistence_failure_does_not_change_dispatch_result(
+ tmp_path, monkeypatch
+):
+ """A telemetry write failure cannot erase or alter a successful spawn."""
+ from hermes_cli import kanban_db as kb
+
+ home = tmp_path / ".hermes"
+ home.mkdir()
+ monkeypatch.setenv("HERMES_HOME", str(home))
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ kb.init_db()
+ monkeypatch.setattr("hermes_cli.profiles.profile_exists", lambda _name: True)
+
+ spawned = []
+ with kb.connect() as conn:
+ task_id = kb.create_task(conn, title="spawn-me", assignee="worker")
+ result = kb.dispatch_once(
+ conn, spawn_fn=lambda task, workspace: spawned.append(task.id)
+ )
+
+ def fail_write(_snapshot):
+ raise OSError("read-only health directory")
+
+ assert _persist_dispatcher_health(fail_write, {"status": "ok"}) is False
+ assert spawned == [task_id]
+ assert result.spawned[0][0] == task_id
+
+
def test_telemetry_review_runs_once_per_nominal_boundary():
phoenix = ZoneInfo("America/Phoenix")
now = int(datetime(2026, 7, 30, 12, 30, tzinfo=phoenix).timestamp())
@@ -41,5 +178,3 @@ def test_telemetry_review_runs_once_per_nominal_boundary():
assert due is True
assert duplicate_due is False
assert duplicate_boundary == boundary
-
-
diff --git a/tests/gateway/test_status.py b/tests/gateway/test_status.py
index 64582219c422..237331782bb8 100644
--- a/tests/gateway/test_status.py
+++ b/tests/gateway/test_status.py
@@ -248,6 +248,15 @@ def test_runtime_status_running_pid_accepts_matching_profile_cmdline(self, monke
), cmdline
+ def test_command_line_belongs_to_profile_normalizes_separators(self):
+ """A Windows argv renders HERMES_HOME with backslashes while the
+ profile's Path may carry forward slashes (and, on Windows, vice
+ versa). The separator difference must not defeat the match."""
+ home = Path("c:/opt/data/profiles/coder")
+ cmdline = r"hermes_home=c:\opt\data\profiles\coder hermes gateway run --replace"
+ assert status._command_line_belongs_to_profile(cmdline, home) is True
+
+
def test_write_runtime_status_explicit_none_clears_stale_fields(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
diff --git a/tests/hermes_cli/test_banner.py b/tests/hermes_cli/test_banner.py
index 9493d40de3d8..e1dbed119522 100644
--- a/tests/hermes_cli/test_banner.py
+++ b/tests/hermes_cli/test_banner.py
@@ -9,6 +9,16 @@
import tools.mcp_tool
+def test_cprint_falls_back_to_plain_print_when_prompt_toolkit_has_no_console(capsys):
+ with patch(
+ "prompt_toolkit.print_formatted_text",
+ side_effect=RuntimeError("no console screen buffer"),
+ ):
+ banner.cprint("fallback text")
+
+ assert capsys.readouterr().out == "fallback text\n"
+
+
diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py
index 82621637c78c..56d66e67e25f 100644
--- a/tests/hermes_cli/test_kanban_db.py
+++ b/tests/hermes_cli/test_kanban_db.py
@@ -14,7 +14,6 @@
import pytest
-import hermes_state
from hermes_cli import kanban_db as kb
@@ -43,10 +42,40 @@ def _init_git_repo(repo: Path) -> None:
# Schema / init
# ---------------------------------------------------------------------------
+def test_init_db_is_idempotent(kanban_home):
+ # Second call should not error or drop data.
+ with kb.connect() as conn:
+ kb.create_task(conn, title="persisted")
+ kb.init_db()
+ with kb.connect() as conn:
+ tasks = kb.list_tasks(conn)
+ assert len(tasks) == 1
+ assert tasks[0].title == "persisted"
+def test_init_creates_expected_tables(kanban_home):
+ with kb.connect() as conn:
+ rows = conn.execute(
+ "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
+ ).fetchall()
+ names = {r["name"] for r in rows}
+ assert {"tasks", "task_links", "task_comments", "task_events"} <= names
+def test_connect_honors_kanban_busy_timeout_env(kanban_home, monkeypatch):
+ """All kanban connections should use the explicit busy-timeout knob.
+
+ A worker stampede should wait for SQLite's writer lock instead of failing
+ immediately with ``database is locked`` during first-connect/WAL/schema
+ setup. The timeout must be queryable via PRAGMA so CLI, gateway, and tool
+ connections behave the same way.
+ """
+ monkeypatch.setenv("HERMES_KANBAN_BUSY_TIMEOUT_MS", "123456")
+
+ with kb.connect() as conn:
+ row = conn.execute("PRAGMA busy_timeout").fetchone()
+
+ assert row[0] == 123456
def test_cross_process_init_lock_uses_windows_byte_range_lock(tmp_path, monkeypatch):
@@ -77,6 +106,27 @@ def test_cross_process_init_lock_uses_windows_byte_range_lock(tmp_path, monkeypa
]
+def test_connect_rejects_tls_record_in_sqlite_header(tmp_path, monkeypatch):
+ """Kanban should classify TLS-looking page-0 clobbers before WAL setup."""
+ home = tmp_path / ".hermes"
+ home.mkdir()
+ monkeypatch.setenv("HERMES_HOME", str(home))
+ monkeypatch.delenv("HERMES_KANBAN_DB", raising=False)
+ monkeypatch.delenv("HERMES_KANBAN_HOME", raising=False)
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+
+ corrupt = home / "kanban.db"
+ corrupt.write_bytes(b"SQLit" + bytes.fromhex("17 03 03 00 13") + b"x" * 32)
+
+ with pytest.raises(sqlite3.DatabaseError) as exc_info:
+ kb.connect(board="default")
+
+ msg = str(exc_info.value)
+ assert "file is not a database" in msg
+ assert "TLS record header detected at byte offset 5" in msg
+ assert "53 51 4c 69 74 17 03 03 00 13" in msg
+
+
def test_connect_migrates_legacy_db_before_optional_column_indexes(tmp_path):
"""Legacy DBs missing additive indexed columns must migrate cleanly.
@@ -162,22 +212,189 @@ def test_connect_migrates_legacy_db_before_optional_column_indexes(tmp_path):
# Task creation + status inference
# ---------------------------------------------------------------------------
+def test_create_task_no_parents_is_ready(kanban_home):
+ with kb.connect() as conn:
+ tid = kb.create_task(conn, title="ship it", assignee="alice")
+ t = kb.get_task(conn, tid)
+ assert t is not None
+ assert t.status == "ready"
+ assert t.assignee == "alice"
+ assert t.workspace_kind == "scratch"
+
+
+def test_create_task_with_parent_is_todo_until_parent_done(kanban_home):
+ with kb.connect() as conn:
+ p = kb.create_task(conn, title="parent")
+ c = kb.create_task(conn, title="child", parents=[p])
+ assert kb.get_task(conn, c).status == "todo"
+ kb.complete_task(conn, p, result="ok")
+ assert kb.get_task(conn, c).status == "ready"
+
+
+def test_create_task_unknown_parent_errors(kanban_home):
+ with kb.connect() as conn, pytest.raises(ValueError, match="unknown parent"):
+ kb.create_task(conn, title="orphan", parents=["t_ghost"])
+
+
+def test_workspace_kind_validation(kanban_home):
+ with kb.connect() as conn, pytest.raises(ValueError, match="workspace_kind"):
+ kb.create_task(conn, title="bad ws", workspace_kind="cloud")
+
+
+def test_create_task_persists_worktree_branch_name(kanban_home, tmp_path):
+ target = tmp_path / ".worktrees" / "t6-wire"
+ with kb.connect() as conn:
+ tid = kb.create_task(
+ conn,
+ title="ship worktree",
+ workspace_kind="worktree",
+ workspace_path=str(target),
+ branch_name=" wt/t6-wire ",
+ )
+ task = kb.get_task(conn, tid)
+ events = kb.list_events(conn, tid)
+ context = kb.build_worker_context(conn, tid)
+
+ assert task.branch_name == "wt/t6-wire"
+ assert events[0].payload["branch_name"] == "wt/t6-wire"
+ assert "Branch: wt/t6-wire" in context
+
+
+def test_branch_name_requires_worktree_workspace(kanban_home):
+ with kb.connect() as conn, pytest.raises(ValueError, match="worktree"):
+ kb.create_task(
+ conn,
+ title="bad branch",
+ workspace_kind="scratch",
+ branch_name="wt/bad",
+ )
# ---------------------------------------------------------------------------
# Links + dependency resolution
# ---------------------------------------------------------------------------
+def test_link_demotes_ready_child_to_todo_when_parent_not_done(kanban_home):
+ with kb.connect() as conn:
+ a = kb.create_task(conn, title="a")
+ b = kb.create_task(conn, title="b")
+ assert kb.get_task(conn, b).status == "ready"
+ kb.link_tasks(conn, a, b)
+ assert kb.get_task(conn, b).status == "todo"
+
+
+def test_link_keeps_ready_child_when_parent_already_done(kanban_home):
+ with kb.connect() as conn:
+ a = kb.create_task(conn, title="a")
+ kb.complete_task(conn, a)
+ b = kb.create_task(conn, title="b")
+ assert kb.get_task(conn, b).status == "ready"
+ kb.link_tasks(conn, a, b)
+ assert kb.get_task(conn, b).status == "ready"
+def test_link_rejects_self_loop(kanban_home):
+ with kb.connect() as conn:
+ a = kb.create_task(conn, title="a")
+ with pytest.raises(ValueError, match="itself"):
+ kb.link_tasks(conn, a, a)
+def test_link_detects_cycle(kanban_home):
+ with kb.connect() as conn:
+ a = kb.create_task(conn, title="a")
+ b = kb.create_task(conn, title="b", parents=[a])
+ c = kb.create_task(conn, title="c", parents=[b])
+ with pytest.raises(ValueError, match="cycle"):
+ kb.link_tasks(conn, c, a)
+ with pytest.raises(ValueError, match="cycle"):
+ kb.link_tasks(conn, b, a)
+
+
+def test_recompute_ready_cascades_through_chain(kanban_home):
+ with kb.connect() as conn:
+ a = kb.create_task(conn, title="a")
+ b = kb.create_task(conn, title="b", parents=[a])
+ c = kb.create_task(conn, title="c", parents=[b])
+ assert [kb.get_task(conn, x).status for x in (a, b, c)] == \
+ ["ready", "todo", "todo"]
+ kb.complete_task(conn, a)
+ assert kb.get_task(conn, b).status == "ready"
+ kb.complete_task(conn, b)
+ assert kb.get_task(conn, c).status == "ready"
+
+
+def test_recompute_ready_promotes_blocked_with_done_parents(kanban_home):
+ """blocked tasks with all parents done should be promoted to ready,
+ unless the circuit-breaker failure limit has been reached."""
+ with kb.connect() as conn:
+ parent = kb.create_task(conn, title="parent", assignee="a")
+ child = kb.create_task(
+ conn, title="child", assignee="a", parents=[parent],
+ )
+ # Complete the parent
+ kb.claim_task(conn, parent)
+ kb.complete_task(conn, parent, result="ok")
+ # Manually block the child with zero failures (simulates a
+ # dependency block, not a circuit-breaker block).
+ conn.execute(
+ "UPDATE tasks SET status='blocked', consecutive_failures=0, "
+ "last_failure_error=NULL WHERE id=?",
+ (child,),
+ )
+ conn.commit()
+ assert kb.get_task(conn, child).status == "blocked"
+ # recompute_ready should promote blocked → ready
+ promoted = kb.recompute_ready(conn)
+ assert promoted == 1
+ task = kb.get_task(conn, child)
+ assert task.status == "ready"
+ assert task.consecutive_failures == 0
+ assert task.last_failure_error is None
+
+
+def test_recompute_ready_fan_in_waits_for_all_parents(kanban_home):
+ with kb.connect() as conn:
+ a = kb.create_task(conn, title="a")
+ b = kb.create_task(conn, title="b")
+ c = kb.create_task(conn, title="c", parents=[a, b])
+ kb.complete_task(conn, a)
+ assert kb.get_task(conn, c).status == "todo"
+ kb.complete_task(conn, b)
+ assert kb.get_task(conn, c).status == "ready"
# ---------------------------------------------------------------------------
# Atomic claim (CAS)
# ---------------------------------------------------------------------------
+def test_claim_once_wins_second_loses(kanban_home):
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="x", assignee="a")
+ first = kb.claim_task(conn, t, claimer="host:1")
+ assert first is not None and first.status == "running"
+ second = kb.claim_task(conn, t, claimer="host:2")
+ assert second is None
+
+
+def test_claim_uses_env_default_ttl(kanban_home, monkeypatch):
+ monkeypatch.setenv("HERMES_KANBAN_CLAIM_TTL_SECONDS", "3600")
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="x", assignee="a")
+ kb.claim_task(conn, t, claimer="host:1")
+ expires = kb.get_task(conn, t).claim_expires
+ assert expires is not None
+ assert expires > int(time.time()) + 3000
+
+
+def test_claim_fails_on_non_ready(kanban_home):
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="x")
+ # Move to todo by introducing an unsatisfied parent.
+ p = kb.create_task(conn, title="p")
+ kb.link_tasks(conn, p, t)
+ assert kb.get_task(conn, t).status == "todo"
+ assert kb.claim_task(conn, t) is None
def test_schedule_task_parks_time_delay_without_dispatching(kanban_home):
@@ -192,10 +409,283 @@ def test_schedule_task_parks_time_delay_without_dispatching(kanban_home):
assert any(e.kind == "scheduled" and e.payload == {"reason": "run next week"} for e in events)
+def test_unblock_scheduled_rechecks_parent_gate(kanban_home):
+ with kb.connect() as conn:
+ parent = kb.create_task(conn, title="parent")
+ child = kb.create_task(conn, title="child", parents=[parent])
+ assert kb.get_task(conn, child).status == "todo"
+ assert kb.schedule_task(conn, child, reason="wait until tomorrow") is True
+
+ assert kb.unblock_task(conn, child) is True
+ assert kb.get_task(conn, child).status == "todo"
+
+ kb.complete_task(conn, parent)
+ assert kb.schedule_task(conn, child, reason="second timer") is True
+ assert kb.unblock_task(conn, child) is True
+ assert kb.get_task(conn, child).status == "ready"
+
+
+def test_stale_claim_reclaimed(kanban_home, monkeypatch):
+ import signal
+ import hermes_cli.kanban_db as _kb
+
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="x", assignee="a")
+ host = _kb._claimer_id().split(":", 1)[0]
+ kb.claim_task(conn, t, claimer=f"{host}:worker")
+ killed: list[int] = []
+
+ def _signal(_pid, sig):
+ killed.append(sig)
+
+ kb._set_worker_pid(conn, t, 12345)
+ # Rewind claim_expires so it looks stale.
+ conn.execute(
+ "UPDATE tasks SET claim_expires = ? WHERE id = ?",
+ (int(time.time()) - 3600, t),
+ )
+ # Worker PID has died — exactly the case ``release_stale_claims``
+ # should still reclaim (post-#23025: live PIDs are now extended).
+ monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: False)
+ reclaimed = kb.release_stale_claims(conn, signal_fn=_signal)
+ assert reclaimed == 1
+ assert kb.get_task(conn, t).status == "ready"
+ assert killed == [signal.SIGTERM]
+
+
+def test_stale_claim_with_live_pid_extends_instead_of_reclaiming(
+ kanban_home, monkeypatch,
+):
+ """A stale-by-TTL claim whose worker PID is still alive should be
+ extended, not reclaimed (#23025). Slow models can spend longer than
+ ``DEFAULT_CLAIM_TTL_SECONDS`` inside a single tool-free LLM call;
+ killing those healthy workers produces a respawn loop with zero
+ progress."""
+ import hermes_cli.kanban_db as _kb
+
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="x", assignee="a")
+ host = _kb._claimer_id().split(":", 1)[0]
+ kb.claim_task(conn, t, claimer=f"{host}:worker")
+ kb._set_worker_pid(conn, t, 12345)
+
+ old_expires = int(time.time()) - 60
+ conn.execute(
+ "UPDATE tasks SET claim_expires = ? WHERE id = ?",
+ (old_expires, t),
+ )
+
+ monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: True)
+ killed: list[int] = []
+ reclaimed = kb.release_stale_claims(
+ conn, signal_fn=lambda _p, sig: killed.append(sig),
+ )
+ assert reclaimed == 0
+ task = kb.get_task(conn, t)
+ assert task.status == "running"
+ assert task.claim_expires is not None
+ assert task.claim_expires > old_expires
+ assert killed == [] # live worker not killed
+
+ kinds = [
+ r["kind"] for r in conn.execute(
+ "SELECT kind FROM task_events WHERE task_id = ?", (t,),
+ ).fetchall()
+ ]
+ assert "claim_extended" in kinds
+ assert "reclaimed" not in kinds
+
+
+def test_stale_claim_with_live_pid_uses_env_ttl_override(
+ kanban_home, monkeypatch,
+):
+ import hermes_cli.kanban_db as _kb
+
+ monkeypatch.setenv("HERMES_KANBAN_CLAIM_TTL_SECONDS", "3600")
+
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="x", assignee="a")
+ host = _kb._claimer_id().split(":", 1)[0]
+ kb.claim_task(conn, t, claimer=f"{host}:worker")
+ kb._set_worker_pid(conn, t, 12345)
+ conn.execute(
+ "UPDATE tasks SET claim_expires = ? WHERE id = ?",
+ (int(time.time()) - 60, t),
+ )
+
+ monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: True)
+ reclaimed = kb.release_stale_claims(conn, signal_fn=lambda _p, _s: None)
+ assert reclaimed == 0
+
+ task = kb.get_task(conn, t)
+ assert task is not None
+ assert task.claim_expires is not None
+ assert task.claim_expires > int(time.time()) + 3000
+def test_stale_claim_deferred_when_live_worker_survives_termination(
+ kanban_home, monkeypatch,
+):
+ """A TTL-expired claim whose worker survives the kill must NOT be released.
+ Releasing would let the dispatcher spawn a duplicate beside the still-alive
+ worker — the runaway seen when a cgroup memory.high throttle parks a worker
+ in uninterruptible (D) state, where a pending SIGKILL cannot land. The claim
+ is held (extended) and retried next tick instead.
+ """
+ import hermes_cli.kanban_db as _kb
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="x", assignee="a")
+ host = _kb._claimer_id().split(":", 1)[0]
+ kb.claim_task(conn, t, claimer=f"{host}:worker")
+ kb._set_worker_pid(conn, t, 12345)
+
+ old_expires = int(time.time()) - 60
+ # Heartbeat stale by > 1h so the live-pid EXTEND branch is skipped and
+ # the terminate path (the wedged-worker case) runs.
+ conn.execute(
+ "UPDATE tasks SET claim_expires = ?, last_heartbeat_at = ? "
+ "WHERE id = ?",
+ (old_expires, int(time.time()) - 7200, t),
+ )
+ monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: True)
+ monkeypatch.setattr(
+ _kb, "_terminate_reclaimed_worker",
+ lambda *a, **k: {
+ "termination_attempted": True,
+ "host_local": True,
+ "terminated": False,
+ },
+ )
+ reclaimed = kb.release_stale_claims(conn, signal_fn=lambda _p, _s: None)
+ assert reclaimed == 0
+
+ assert kb.get_task(conn, t).status == "running"
+ worker_pid = conn.execute(
+ "SELECT worker_pid FROM tasks WHERE id = ?", (t,),
+ ).fetchone()[0]
+ assert worker_pid == 12345 # worker not orphaned
+ claim_expires = conn.execute(
+ "SELECT claim_expires FROM tasks WHERE id = ?", (t,),
+ ).fetchone()[0]
+ assert claim_expires > old_expires # claim held, not released
+
+ kinds = [
+ r["kind"] for r in conn.execute(
+ "SELECT kind FROM task_events WHERE task_id = ?", (t,),
+ ).fetchall()
+ ]
+ assert "reclaim_deferred" in kinds
+ assert "reclaimed" not in kinds
+
+
+def test_stale_claim_reclaimed_when_termination_succeeds(
+ kanban_home, monkeypatch,
+):
+ """When the worker is actually killed, the claim is released as before."""
+ import hermes_cli.kanban_db as _kb
+
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="x", assignee="a")
+ host = _kb._claimer_id().split(":", 1)[0]
+ kb.claim_task(conn, t, claimer=f"{host}:worker")
+ kb._set_worker_pid(conn, t, 12345)
+ conn.execute(
+ "UPDATE tasks SET claim_expires = ?, last_heartbeat_at = ? "
+ "WHERE id = ?",
+ (int(time.time()) - 60, int(time.time()) - 7200, t),
+ )
+ monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: False)
+ monkeypatch.setattr(
+ _kb, "_terminate_reclaimed_worker",
+ lambda *a, **k: {
+ "termination_attempted": True,
+ "host_local": True,
+ "terminated": True,
+ },
+ )
+ reclaimed = kb.release_stale_claims(conn, signal_fn=lambda _p, _s: None)
+ assert reclaimed == 1
+ assert kb.get_task(conn, t).status == "ready"
+
+
+def test_stale_claim_released_when_worker_not_host_local(
+ kanban_home, monkeypatch,
+):
+ """The defer guard only holds OUR own surviving workers.
+
+ A claim we cannot manage (different host, or no kill attempted) must still
+ be released, otherwise a foreign-host claim could strand a task forever.
+ """
+ import hermes_cli.kanban_db as _kb
+
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="x", assignee="a")
+ host = _kb._claimer_id().split(":", 1)[0]
+ kb.claim_task(conn, t, claimer=f"{host}:worker")
+ kb._set_worker_pid(conn, t, 12345)
+ conn.execute(
+ "UPDATE tasks SET claim_expires = ?, last_heartbeat_at = ? "
+ "WHERE id = ?",
+ (int(time.time()) - 60, int(time.time()) - 7200, t),
+ )
+ monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: True)
+ monkeypatch.setattr(
+ _kb, "_terminate_reclaimed_worker",
+ lambda *a, **k: {
+ "termination_attempted": False,
+ "host_local": False,
+ "terminated": False,
+ },
+ )
+ reclaimed = kb.release_stale_claims(conn, signal_fn=lambda _p, _s: None)
+ assert reclaimed == 1
+ assert kb.get_task(conn, t).status == "ready"
+
+
+def test_detect_stale_defers_when_live_worker_survives(kanban_home, monkeypatch):
+ """detect_stale_running must also hold the claim when the worker survives."""
+ import hermes_cli.kanban_db as _kb
+
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="wedged", assignee="worker")
+ kb.claim_task(conn, t)
+ kb._set_worker_pid(conn, t, os.getpid())
+
+ five_hours_ago = int(time.time()) - (5 * 3600)
+ with kb.write_txn(conn):
+ conn.execute(
+ "UPDATE tasks SET started_at = ?, last_heartbeat_at = NULL "
+ "WHERE id = ?",
+ (five_hours_ago, t),
+ )
+ conn.execute(
+ "UPDATE task_runs SET started_at = ? "
+ "WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)",
+ (five_hours_ago, t),
+ )
+
+ monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: True)
+ monkeypatch.setattr(
+ _kb, "_terminate_reclaimed_worker",
+ lambda *a, **k: {
+ "termination_attempted": True,
+ "host_local": True,
+ "terminated": False,
+ },
+ )
+ stale = kb.detect_stale_running(
+ conn, stale_timeout_seconds=14400, signal_fn=lambda p, s: None,
+ )
+ assert stale == []
+ assert kb.get_task(conn, t).status == "running"
+ kinds = [
+ r["kind"] for r in conn.execute(
+ "SELECT kind FROM task_events WHERE task_id = ?", (t,),
+ ).fetchall()
+ ]
+ assert "reclaim_deferred" in kinds
def test_stale_claim_reclaim_event_records_diagnostic_payload(
@@ -236,8 +726,140 @@ def test_stale_claim_reclaim_event_records_diagnostic_payload(
assert payload["host_local"] is True
+def test_detect_crashed_workers_systemic_failure_fast_block(
+ kanban_home, monkeypatch,
+):
+ """When many tasks crash with the same error, trip the breaker faster."""
+ import hermes_cli.kanban_db as _kb
+
+ monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: False)
+
+ with kb.connect() as conn:
+ task_ids = []
+ for i in range(4):
+ tid = kb.create_task(conn, title=f"task-{i}", assignee="a")
+ host = _kb._claimer_id().split(":", 1)[0]
+ conn.execute(
+ "UPDATE tasks SET status='running', worker_pid=?, "
+ "claim_lock=? WHERE id=?",
+ (90000 + i, f"{host}:w{i}", tid),
+ )
+ task_ids.append(tid)
+ conn.commit()
+
+ crashed = kb.detect_crashed_workers(conn)
+ assert len(crashed) == 4
+
+ for tid in task_ids:
+ task = kb.get_task(conn, tid)
+ assert task.status == "blocked", (
+ f"task {tid} should be blocked (systemic), got {task.status}"
+ )
+
+
+def test_detect_crashed_workers_isolated_failure_normal_retry(
+ kanban_home, monkeypatch,
+):
+ """Below the systemic threshold, tasks retain normal retry budget."""
+ import hermes_cli.kanban_db as _kb
+
+ monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: False)
+
+ with kb.connect() as conn:
+ task_ids = []
+ for i in range(2):
+ tid = kb.create_task(conn, title=f"iso-{i}", assignee="a")
+ host = _kb._claimer_id().split(":", 1)[0]
+ conn.execute(
+ "UPDATE tasks SET status='running', worker_pid=?, "
+ "claim_lock=? WHERE id=?",
+ (80000 + i, f"{host}:w{i}", tid),
+ )
+ task_ids.append(tid)
+ conn.commit()
+
+ crashed = kb.detect_crashed_workers(conn)
+ assert len(crashed) == 2
+
+ for tid in task_ids:
+ task = kb.get_task(conn, tid)
+ assert task.status == "ready", (
+ f"task {tid} should stay ready (isolated), got {task.status}"
+ )
+def test_detect_crashed_workers_skips_freshly_claimed_tasks(
+ kanban_home, monkeypatch,
+):
+ """Grace period prevents reclaim of freshly-started tasks."""
+ import hermes_cli.kanban_db as _kb
+
+ monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: False)
+ monkeypatch.delenv("HERMES_KANBAN_CRASH_GRACE_SECONDS", raising=False)
+
+ now = 1_000_000.0
+ monkeypatch.setattr(_kb.time, "time", lambda: now)
+
+ with kb.connect() as conn:
+ host = _kb._claimer_id().split(":", 1)[0]
+ tid = kb.create_task(conn, title="grace test", assignee="a")
+ conn.execute(
+ "UPDATE tasks SET status='running', worker_pid=?, "
+ "claim_lock=?, started_at=? WHERE id=?",
+ (99999, f"{host}:w", int(now), tid),
+ )
+ conn.commit()
+
+ # With time = now (just claimed), grace period should suppress reclaim.
+ crashed = kb.detect_crashed_workers(conn)
+ assert tid not in crashed, "should not reclaim freshly-started task"
+
+ # With time = now + 60 (past default 30s grace), should reclaim.
+ monkeypatch.setattr(_kb.time, "time", lambda: now + 60)
+ crashed = kb.detect_crashed_workers(conn)
+ assert tid in crashed, "should reclaim task past grace period"
+
+
+def test_detect_crashed_workers_grace_period_env_override(
+ kanban_home, monkeypatch,
+):
+ """HERMES_KANBAN_CRASH_GRACE_SECONDS env var adjusts the window."""
+ import hermes_cli.kanban_db as _kb
+
+ monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: False)
+ monkeypatch.setenv("HERMES_KANBAN_CRASH_GRACE_SECONDS", "5")
+
+ now = 2_000_000.0
+
+ with kb.connect() as conn:
+ host = _kb._claimer_id().split(":", 1)[0]
+ tid = kb.create_task(conn, title="env override test", assignee="a")
+ conn.execute(
+ "UPDATE tasks SET status='running', worker_pid=?, "
+ "claim_lock=?, started_at=? WHERE id=?",
+ (99999, f"{host}:w", int(now), tid),
+ )
+ conn.commit()
+
+ # 3s after claim: within 5s grace → no reclaim.
+ monkeypatch.setattr(_kb.time, "time", lambda: now + 3)
+ assert tid not in kb.detect_crashed_workers(conn)
+
+ # 6s after claim: past 5s grace → reclaim.
+ monkeypatch.setattr(_kb.time, "time", lambda: now + 6)
+ assert tid in kb.detect_crashed_workers(conn)
+
+
+def test_resolve_crash_grace_seconds_handles_bad_env(monkeypatch):
+ """Bad env values fall back to DEFAULT_CRASH_GRACE_SECONDS."""
+ import hermes_cli.kanban_db as _kb
+
+ for bad_val in ("notanumber", "-5", ""):
+ monkeypatch.setenv("HERMES_KANBAN_CRASH_GRACE_SECONDS", bad_val)
+ result = _kb._resolve_crash_grace_seconds()
+ assert result == _kb.DEFAULT_CRASH_GRACE_SECONDS, (
+ f"expected default for {bad_val!r}, got {result}"
+ )
# ---------------------------------------------------------------------------
@@ -254,6 +876,18 @@ def _exited_status(code: int) -> int:
return code << 8
+def test_classify_worker_exit_recognizes_rate_limit_sentinel(kanban_home):
+ import hermes_cli.kanban_db as _kb
+
+ pid = 31337
+ _kb._record_worker_exit(pid, _exited_status(_kb.KANBAN_RATE_LIMIT_EXIT_CODE))
+ kind, code = _kb._classify_worker_exit(pid)
+ assert kind == "rate_limited"
+ assert code == _kb.KANBAN_RATE_LIMIT_EXIT_CODE
+
+ # Plain non-zero exit is still a normal crash, not rate-limited.
+ _kb._record_worker_exit(pid + 1, _exited_status(1))
+ assert _kb._classify_worker_exit(pid + 1) == ("nonzero_exit", 1)
def test_rate_limit_exit_requeues_without_counting_failure(
@@ -318,6 +952,33 @@ def test_rate_limit_exit_requeues_without_counting_failure(
assert "crashed" not in outcomes
+def test_real_crash_still_counts_and_trips_breaker(kanban_home, monkeypatch):
+ """Sanity: a genuine non-zero crash (not the sentinel) still increments
+ the failure counter and trips the breaker — the rate-limit carve-out is
+ surgical, not a blanket "never count crashes"."""
+ import hermes_cli.kanban_db as _kb
+
+ monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: False)
+
+ with kb.connect() as conn:
+ host = _kb._claimer_id().split(":", 1)[0]
+ tid = kb.create_task(conn, title="crash", assignee="a")
+
+ for i in range(2): # DEFAULT_FAILURE_LIMIT == 2
+ pid = 60000 + i
+ conn.execute(
+ "UPDATE tasks SET status='running', worker_pid=?, "
+ "claim_lock=? WHERE id=?",
+ (pid, f"{host}:w{i}", tid),
+ )
+ conn.commit()
+ _kb._record_worker_exit(pid, _exited_status(1)) # generic failure
+ kb.detect_crashed_workers(conn)
+
+ task = kb.get_task(conn, tid)
+ assert task.status == "blocked", (
+ f"genuine crashes should still trip the breaker, got {task.status}"
+ )
def test_respawn_guard_defers_rate_limited_within_cooldown(
@@ -359,21 +1020,261 @@ def test_respawn_guard_defers_rate_limited_within_cooldown(
assert kb.check_respawn_guard(conn, tid) is None
+def test_respawn_guard_rate_limit_cooldown_zero_allows_immediately(
+ kanban_home, monkeypatch,
+):
+ """Cooldown of 0 disables the wait — task is spawnable on the next tick,
+ and the stamped rate-limit text does not re-trap it via blocker_auth."""
+ import hermes_cli.kanban_db as _kb
+
+ monkeypatch.setenv("HERMES_KANBAN_RATE_LIMIT_COOLDOWN_SECONDS", "0")
+ now = 6_000_000
+
+ with kb.connect() as conn:
+ tid = kb.create_task(conn, title="rl-zero", assignee="a")
+ kb.claim_task(conn, tid)
+ run_id = kb.get_task(conn, tid).current_run_id
+ conn.execute(
+ "UPDATE task_runs SET outcome='rate_limited', status='rate_limited', "
+ "ended_at=? WHERE id=?",
+ (now, run_id),
+ )
+ conn.execute(
+ "UPDATE tasks SET status='ready', current_run_id=NULL, "
+ "claim_lock=NULL, last_failure_error=? WHERE id=?",
+ ("pid 1 exited rate-limited (quota wall)", tid),
+ )
+ conn.commit()
+
+ monkeypatch.setattr(_kb.time, "time", lambda: now + 1)
+ assert kb.check_respawn_guard(conn, tid) is None
+
+
+def test_resolve_rate_limit_cooldown_handles_bad_env(monkeypatch):
+ import hermes_cli.kanban_db as _kb
+
+ for bad_val in ("notanumber", "-5", ""):
+ monkeypatch.setenv(
+ "HERMES_KANBAN_RATE_LIMIT_COOLDOWN_SECONDS", bad_val
+ )
+ assert (
+ _kb._resolve_rate_limit_cooldown_seconds()
+ == _kb.DEFAULT_RATE_LIMIT_COOLDOWN_SECONDS
+ )
+
+
+def test_max_runtime_uses_current_run_start_after_retry(kanban_home, monkeypatch):
+ """A retry should get a fresh max-runtime window.
+
+ ``tasks.started_at`` intentionally records the first time the task ever
+ started. Runtime enforcement must therefore use the active
+ ``task_runs.started_at`` row; otherwise every retry of an old task is
+ immediately timed out again.
+ """
+ monkeypatch.setattr(kb, "_pid_alive", lambda _pid: False)
+
+ with kb.connect() as conn:
+ host = kb._claimer_id().split(":", 1)[0]
+ t = kb.create_task(
+ conn, title="retry", assignee="a", max_runtime_seconds=10,
+ )
+
+ kb.claim_task(conn, t, claimer=f"{host}:first")
+ first_run_id = kb.latest_run(conn, t).id
+ old_started = int(time.time()) - 20
+ conn.execute(
+ "UPDATE tasks SET started_at = ?, worker_pid = ? WHERE id = ?",
+ (old_started, 999999, t),
+ )
+ conn.execute(
+ "UPDATE task_runs SET started_at = ?, worker_pid = ? WHERE id = ?",
+ (old_started, 999999, first_run_id),
+ )
+
+ timed_out = kb.enforce_max_runtime(conn, signal_fn=lambda _pid, _sig: None)
+ assert timed_out == [t]
+ assert kb.get_task(conn, t).status == "ready"
+
+ kb.claim_task(conn, t, claimer=f"{host}:retry")
+ retry_run = kb.latest_run(conn, t)
+ conn.execute(
+ "UPDATE tasks SET worker_pid = ? WHERE id = ?",
+ (999999, t),
+ )
+ conn.execute(
+ "UPDATE task_runs SET worker_pid = ? WHERE id = ?",
+ (999999, retry_run.id),
+ )
+ timed_out = kb.enforce_max_runtime(conn, signal_fn=lambda _pid, _sig: None)
+ assert timed_out == []
+ assert kb.get_task(conn, t).status == "running"
+def test_heartbeat_extends_claim(kanban_home):
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="x", assignee="a")
+ claimer = "host:hb"
+ kb.claim_task(conn, t, claimer=claimer, ttl_seconds=60)
+ original = kb.get_task(conn, t).claim_expires
+ # Rewind then heartbeat.
+ conn.execute("UPDATE tasks SET claim_expires = ? WHERE id = ?", (0, t))
+ ok = kb.heartbeat_claim(conn, t, claimer=claimer, ttl_seconds=3600)
+ assert ok
+ new = kb.get_task(conn, t).claim_expires
+ assert new > int(time.time()) + 3000
+
+
+def test_heartbeat_uses_env_default_ttl(kanban_home, monkeypatch):
+ monkeypatch.setenv("HERMES_KANBAN_CLAIM_TTL_SECONDS", "3600")
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="x", assignee="a")
+ claimer = "host:hb"
+ kb.claim_task(conn, t, claimer=claimer, ttl_seconds=60)
+ conn.execute("UPDATE tasks SET claim_expires = ? WHERE id = ?", (0, t))
+ ok = kb.heartbeat_claim(conn, t, claimer=claimer)
+ assert ok
+ new = kb.get_task(conn, t).claim_expires
+ assert new is not None
+ assert new > int(time.time()) + 3000
+
+
+def test_concurrent_claims_only_one_wins(kanban_home):
+ """Fire N threads claiming the same task; exactly one must win."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="race", assignee="a")
+
+ def attempt(i):
+ with kb.connect() as c:
+ return kb.claim_task(c, t, claimer=f"host:{i}")
+ n_workers = 8
+ with concurrent.futures.ThreadPoolExecutor(max_workers=n_workers) as ex:
+ results = list(ex.map(attempt, range(n_workers)))
+ winners = [r for r in results if r is not None]
+ assert len(winners) == 1
+ assert winners[0].status == "running"
# ---------------------------------------------------------------------------
# Complete / block / unblock / archive / assign
# ---------------------------------------------------------------------------
+def test_complete_records_result(kanban_home):
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="x")
+ assert kb.complete_task(conn, t, result="done and dusted")
+ task = kb.get_task(conn, t)
+ assert task.status == "done"
+ assert task.result == "done and dusted"
+ assert task.completed_at is not None
+def test_block_then_unblock(kanban_home):
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="x", assignee="a")
+ kb.claim_task(conn, t)
+ assert kb.block_task(conn, t, reason="need input")
+ assert kb.get_task(conn, t).status == "blocked"
+ assert kb.unblock_task(conn, t)
+ assert kb.get_task(conn, t).status == "ready"
+
+def test_unblock_resets_failure_counters(kanban_home):
+ """unblock_task must reset consecutive_failures and last_failure_error."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="x", assignee="a")
+ kb.claim_task(conn, t)
+ assert kb.block_task(conn, t, reason="need input")
+ # Simulate accumulated failures from the circuit breaker
+ conn.execute(
+ "UPDATE tasks SET consecutive_failures = 5, "
+ "last_failure_error = 'test error' WHERE id = ?",
+ (t,),
+ )
+ conn.commit()
+ assert kb.unblock_task(conn, t)
+ task = kb.get_task(conn, t)
+ assert task.status == "ready"
+ assert task.consecutive_failures == 0
+ assert task.last_failure_error is None
+def test_recompute_ready_skips_tasks_at_failure_limit(kanban_home):
+ """recompute_ready must not auto-recover tasks whose consecutive_failures
+ has reached the circuit-breaker limit (#35072).
+
+ Without this guard, a task that repeatedly exhausts its iteration
+ budget would cycle forever: block → auto-recover (counter reset)
+ → respawn → budget exhausted → block → …
+ """
+ with kb.connect() as conn:
+ parent = kb.create_task(conn, title="parent", assignee="a")
+ child = kb.create_task(conn, title="child", assignee="a",
+ parents=[parent])
+ # Complete the parent so the child's dependencies are satisfied.
+ kb.claim_task(conn, parent)
+ kb.complete_task(conn, parent, summary="done")
+
+ # Simulate the child having exhausted its budget twice,
+ # hitting the default failure limit (2).
+ kb.claim_task(conn, child)
+ kb._record_task_failure(
+ conn, child, error="budget exhausted 1",
+ outcome="timed_out", release_claim=True, end_run=True,
+ failure_limit=2,
+ )
+ kb._record_task_failure(
+ conn, child, error="budget exhausted 2",
+ outcome="timed_out", release_claim=True, end_run=True,
+ failure_limit=2,
+ )
+ task = kb.get_task(conn, child)
+ assert task.status == "blocked"
+ assert task.consecutive_failures >= 2
+
+ # recompute_ready must NOT promote this task — the circuit
+ # breaker has tripped and it should stay blocked.
+ promoted = kb.recompute_ready(conn)
+ assert promoted == 0
+ assert kb.get_task(conn, child).status == "blocked"
+
+ # Explicit unblock should still work and reset the counter.
+ assert kb.unblock_task(conn, child)
+ task = kb.get_task(conn, child)
+ assert task.status == "ready"
+ assert task.consecutive_failures == 0
+
+
+def test_recompute_ready_recovers_below_limit(kanban_home):
+ """recompute_ready auto-recovers blocked tasks that haven't hit the
+ failure limit yet — the counter is preserved across recovery."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="task", assignee="a")
+ kb.claim_task(conn, t)
+ # One failure, below the default limit of 2.
+ kb._record_task_failure(
+ conn, t, error="budget exhausted 1",
+ outcome="timed_out", release_claim=True, end_run=True,
+ failure_limit=2,
+ )
+ task = kb.get_task(conn, t)
+ assert task.status == "ready"
+ assert task.consecutive_failures == 1
+
+ # Simulate being blocked by something else (not circuit breaker).
+ conn.execute(
+ "UPDATE tasks SET status = 'blocked' WHERE id = ?", (t,),
+ )
+ conn.commit()
+
+ promoted = kb.recompute_ready(conn)
+ assert promoted == 1
+ task = kb.get_task(conn, t)
+ assert task.status == "ready"
+ # Counter must be preserved, not reset.
+ assert task.consecutive_failures == 1
+
def test_recompute_ready_honours_dispatcher_failure_limit(kanban_home):
"""The guard's effective limit must follow the same resolution order
@@ -422,23 +1323,180 @@ def test_recompute_ready_honours_dispatcher_failure_limit(kanban_home):
assert kb.get_task(conn, t2).status == "blocked"
+def test_recompute_ready_per_task_max_retries_overrides_dispatcher(kanban_home):
+ """A per-task ``max_retries`` wins over the dispatcher failure_limit,
+ matching ``_record_task_failure``'s resolution order."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="per-task", assignee="a")
+ # Per-task allows 4 retries; dispatcher config says 2.
+ conn.execute(
+ "UPDATE tasks SET status='blocked', consecutive_failures=2, "
+ "max_retries=4 WHERE id=?",
+ (t,),
+ )
+ conn.commit()
+ # failures(2) < per-task limit(4) → recover, despite dispatcher=2.
+ promoted = kb.recompute_ready(conn, failure_limit=2)
+ assert promoted == 1
+ task = kb.get_task(conn, t)
+ assert task.status == "ready"
+ assert task.consecutive_failures == 2
+
+
+# ---------------------------------------------------------------------------
+# Parent-completion invariant at the claim gate (RCA t_a6acd07d)
+# ---------------------------------------------------------------------------
+
+def test_claim_rejects_when_parents_not_done(kanban_home):
+ """claim_task must refuse ready->running if any parent isn't 'done'.
+
+ Simulates the create-then-link race: a task gets status='ready' via a
+ racy writer while it still has undone parents. The claim gate must
+ detect the violation, demote the child back to 'todo', append a
+ 'claim_rejected' event, and return None. Covers Fix 1 of the RCA.
+ """
+ with kb.connect() as conn:
+ parent = kb.create_task(conn, title="parent", assignee="a")
+ child = kb.create_task(
+ conn, title="child", assignee="a", parents=[parent],
+ )
+ # Child correctly starts 'todo' because parent is not 'done'.
+ assert kb.get_task(conn, child).status == "todo"
+ # Simulate the race: a racy writer force-promotes the child to
+ # 'ready' while parent is still pending.
+ conn.execute(
+ "UPDATE tasks SET status='ready' WHERE id=?", (child,),
+ )
+ conn.commit()
+ assert kb.get_task(conn, child).status == "ready"
+
+ result = kb.claim_task(conn, child, claimer="host:1")
+
+ assert result is None
+ with kb.connect() as conn:
+ assert kb.get_task(conn, child).status == "todo"
+ events = conn.execute(
+ "SELECT kind, payload FROM task_events "
+ "WHERE task_id = ? ORDER BY id",
+ (child,),
+ ).fetchall()
+ kinds = [e["kind"] for e in events]
+ assert "claim_rejected" in kinds
+ # No 'claimed' event was emitted for the blocked attempt.
+ assert "claimed" not in kinds
+
+def test_claim_succeeds_once_parents_done(kanban_home):
+ """After parents complete, recompute_ready -> claim_task must succeed."""
+ with kb.connect() as conn:
+ parent = kb.create_task(conn, title="parent", assignee="a")
+ child = kb.create_task(
+ conn, title="child", assignee="a", parents=[parent],
+ )
+ kb.claim_task(conn, parent)
+ assert kb.complete_task(conn, parent, result="ok")
+ kb.recompute_ready(conn)
+ assert kb.get_task(conn, child).status == "ready"
+ claimed = kb.claim_task(conn, child, claimer="host:1")
+ assert claimed is not None
+ assert claimed.status == "running"
-# ---------------------------------------------------------------------------
-# Parent-completion invariant at the claim gate (RCA t_a6acd07d)
-# ---------------------------------------------------------------------------
+
+def test_create_with_parents_stays_todo_until_parents_done(kanban_home):
+ """kanban_create(parents=[...]) must land in 'todo' and only promote on parent done."""
+ with kb.connect() as conn:
+ parent = kb.create_task(conn, title="parent", assignee="a")
+ child = kb.create_task(
+ conn, title="child", assignee="a", parents=[parent],
+ )
+ assert kb.get_task(conn, child).status == "todo"
+ # Dispatcher tick between create and some later event must NOT
+ # produce a winner for this child.
+ promoted = kb.recompute_ready(conn)
+ assert promoted == 0
+ assert kb.get_task(conn, child).status == "todo"
+ # Complete parent; complete_task internally runs recompute_ready,
+ # which promotes the child to 'ready'.
+ kb.claim_task(conn, parent)
+ kb.complete_task(conn, parent, result="ok")
+ assert kb.get_task(conn, child).status == "ready"
+
+
+def test_unblock_with_pending_parents_goes_to_todo(kanban_home):
+ """unblock_task must re-gate on parent completion (Fix 3).
+
+ A task blocked while parents are still in progress must return to
+ 'todo' (not 'ready') on unblock. Otherwise the dispatcher will claim
+ it immediately, repeating Bug 2 from the RCA.
+ """
+ with kb.connect() as conn:
+ parent = kb.create_task(conn, title="parent", assignee="a")
+ child = kb.create_task(
+ conn, title="child", assignee="a", parents=[parent],
+ )
+ # Force child into 'blocked' regardless of parent progress
+ # (simulates a worker that self-blocked, or an operator block).
+ conn.execute(
+ "UPDATE tasks SET status='blocked' WHERE id=?", (child,),
+ )
+ conn.commit()
+ assert kb.unblock_task(conn, child)
+ assert kb.get_task(conn, child).status == "todo"
+ # After parent completes + recompute, the child is ready.
+ kb.claim_task(conn, parent)
+ kb.complete_task(conn, parent, result="ok")
+ kb.recompute_ready(conn)
+ assert kb.get_task(conn, child).status == "ready"
+def test_unblock_without_parents_goes_to_ready(kanban_home):
+ """Parent-free unblock still produces 'ready' (behavior preserved)."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="lone", assignee="a")
+ kb.claim_task(conn, t)
+ assert kb.block_task(conn, t, reason="need input")
+ assert kb.unblock_task(conn, t)
+ assert kb.get_task(conn, t).status == "ready"
+def test_assign_refuses_while_running(kanban_home):
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="x", assignee="a")
+ kb.claim_task(conn, t)
+ with pytest.raises(RuntimeError, match="currently running"):
+ kb.assign_task(conn, t, "b")
+def test_assign_reassigns_when_not_running(kanban_home):
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="x", assignee="a")
+ assert kb.assign_task(conn, t, "b")
+ assert kb.get_task(conn, t).assignee == "b"
+def test_assignee_normalized_to_lowercase_on_create_and_assign(kanban_home):
+ """Dashboard/CLI may pass title-cased profile labels; DB + spawn use canonical id."""
+ with kb.connect() as conn:
+ tid = kb.create_task(conn, title="cased", assignee="Jules")
+ assert kb.get_task(conn, tid).assignee == "jules"
+ assert kb.assign_task(conn, tid, "Librarian")
+ assert kb.get_task(conn, tid).assignee == "librarian"
+def test_list_tasks_assignee_filter_case_insensitive(kanban_home):
+ with kb.connect() as conn:
+ tid = kb.create_task(conn, title="q", assignee="jules")
+ found = kb.list_tasks(conn, assignee="Jules")
+ assert len(found) == 1 and found[0].id == tid
+def test_archive_hides_from_default_list(kanban_home):
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="x")
+ kb.complete_task(conn, t)
+ assert kb.archive_task(conn, t)
+ assert len(kb.list_tasks(conn)) == 0
+ assert len(kb.list_tasks(conn, include_archived=True)) == 1
def test_delete_archived_task_removes_related_rows(kanban_home):
@@ -465,6 +1523,45 @@ def test_delete_archived_task_removes_related_rows(kanban_home):
assert conn.execute("SELECT COUNT(*) FROM kanban_notify_subs WHERE task_id = ?", (tid,)).fetchone()[0] == 0
+def test_delete_archived_task_rejects_non_archived_rows(kanban_home):
+ with kb.connect() as conn:
+ tid = kb.create_task(conn, title="live")
+ assert kb.delete_archived_task(conn, tid) is False
+ assert kb.get_task(conn, tid) is not None
+
+
+def test_list_tasks_order_by(kanban_home):
+ with kb.connect() as conn:
+ # Create tasks with different titles and priorities
+ t_a = kb.create_task(conn, title="alpha", priority=1)
+ t_b = kb.create_task(conn, title="beta", priority=2)
+ t_c = kb.create_task(conn, title="gamma", priority=1)
+
+ # Default sort: priority DESC, created ASC
+ default = kb.list_tasks(conn)
+ assert [t.id for t in default] == [t_b, t_a, t_c]
+
+ # Sort by title ASC
+ by_title = kb.list_tasks(conn, order_by="title")
+ assert [t.id for t in by_title] == [t_a, t_b, t_c]
+
+ # Sort by assignee
+ kb.assign_task(conn, t_a, "alice")
+ kb.assign_task(conn, t_b, "bob")
+ kb.assign_task(conn, t_c, "alice")
+ by_assignee = kb.list_tasks(conn, order_by="assignee")
+ # alice's tasks first (alphabetically), then bob's
+ assignees = [t.assignee for t in by_assignee]
+ assert assignees[:2] == ["alice", "alice"]
+ assert assignees[2] == "bob"
+
+ # Invalid sort order raises ValueError
+ try:
+ kb.list_tasks(conn, order_by="bogus")
+ assert False, "Should have raised ValueError"
+ except ValueError as e:
+ assert "order_by must be one of" in str(e)
+
def test_delete_task_removes_task_and_cascades(kanban_home):
with kb.connect() as conn:
t = kb.create_task(conn, title="to-delete", assignee="alice")
@@ -477,22 +1574,83 @@ def test_delete_task_removes_task_and_cascades(kanban_home):
assert len(kb.list_runs(conn, t)) == 0
+def test_delete_task_returns_false_for_missing_task(kanban_home):
+ with kb.connect() as conn:
+ assert not kb.delete_task(conn, "t_nonexistent")
+
+
+def test_delete_task_cascades_links(kanban_home):
+ with kb.connect() as conn:
+ p = kb.create_task(conn, title="parent")
+ c = kb.create_task(conn, title="child", parents=[p])
+ child = kb.get_task(conn, c)
+ assert child is not None and child.status == "todo"
+ kb.delete_task(conn, p)
+ assert kb.get_task(conn, p) is None
+ child_after = kb.get_task(conn, c)
+ assert child_after is not None and child_after.status == "ready"
# ---------------------------------------------------------------------------
# Comments / events / worker context
# ---------------------------------------------------------------------------
+def test_comments_recorded_in_order(kanban_home):
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="x")
+ kb.add_comment(conn, t, "user", "first")
+ kb.add_comment(conn, t, "researcher", "second")
+ comments = kb.list_comments(conn, t)
+ assert [c.body for c in comments] == ["first", "second"]
+ assert [c.author for c in comments] == ["user", "researcher"]
+
+
+def test_empty_comment_rejected(kanban_home):
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="x")
+ with pytest.raises(ValueError, match="body is required"):
+ kb.add_comment(conn, t, "user", "")
+def test_events_capture_lifecycle(kanban_home):
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="x", assignee="a")
+ kb.claim_task(conn, t)
+ kb.complete_task(conn, t, result="ok")
+ events = kb.list_events(conn, t)
+ kinds = [e.kind for e in events]
+ assert "created" in kinds
+ assert "claimed" in kinds
+ assert "completed" in kinds
+def test_worker_context_includes_parent_results_and_comments(kanban_home):
+ with kb.connect() as conn:
+ p = kb.create_task(conn, title="p")
+ kb.complete_task(conn, p, result="PARENT_RESULT_MARKER")
+ c = kb.create_task(conn, title="child", parents=[p])
+ kb.add_comment(conn, c, "user", "CLARIFICATION_MARKER")
+ ctx = kb.build_worker_context(conn, c)
+ assert "PARENT_RESULT_MARKER" in ctx
+ assert "CLARIFICATION_MARKER" in ctx
+ assert c in ctx
+ assert "child" in ctx
# ---------------------------------------------------------------------------
# Dispatcher
# ---------------------------------------------------------------------------
+def test_dispatch_dry_run_does_not_claim(kanban_home, all_assignees_spawnable):
+ with kb.connect() as conn:
+ t1 = kb.create_task(conn, title="a", assignee="alice")
+ t2 = kb.create_task(conn, title="b", assignee="bob")
+ res = kb.dispatch_once(conn, dry_run=True)
+ assert {s[0] for s in res.spawned} == {t1, t2}
+ with kb.connect() as conn:
+ # Dry run must NOT mutate status.
+ assert kb.get_task(conn, t1).status == "ready"
+ assert kb.get_task(conn, t2).status == "ready"
def test_dispatch_missing_profile_emits_durable_failure_event(kanban_home, monkeypatch):
"""A missing assignee profile must fail loudly without a spawn loop."""
@@ -639,29 +1797,657 @@ def test_nonspawnable_assignee_health_is_ok_when_queue_is_spawnable(
+def test_dispatch_skips_unassigned(kanban_home):
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="floater")
+ res = kb.dispatch_once(conn, dry_run=True)
+ assert t in res.skipped_unassigned
+ assert t not in res.skipped_nonspawnable
+ assert not res.spawned
+
+
+def test_dispatch_skips_nonspawnable_into_separate_bucket(kanban_home, monkeypatch):
+ """Tasks whose assignee fails profile_exists() must NOT land in
+ ``skipped_unassigned`` (which is operator-actionable) — they go in
+ the dedicated ``skipped_nonspawnable`` bucket so health telemetry
+ can suppress false-positive "stuck" warnings."""
+ from hermes_cli import profiles
+ monkeypatch.setattr(profiles, "profile_exists", lambda name: False)
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="for-terminal", assignee="orion-cc")
+ res = kb.dispatch_once(conn, dry_run=True)
+ assert t in res.skipped_nonspawnable
+ assert t not in res.skipped_unassigned
+ assert not res.spawned
+
+
+def test_has_spawnable_ready_false_when_only_terminal_lanes(kanban_home, monkeypatch):
+ """``has_spawnable_ready`` returns False when every ready task is
+ assigned to a control-plane lane — used by gateway/CLI dispatchers
+ to silence the stuck-warn while terminals still have queued work."""
+ from hermes_cli import profiles
+ monkeypatch.setattr(profiles, "profile_exists", lambda name: False)
+ with kb.connect() as conn:
+ kb.create_task(conn, title="t1", assignee="orion-cc")
+ kb.create_task(conn, title="t2", assignee="orion-research")
+ assert kb.has_spawnable_ready(conn) is False
+
+
+def test_has_spawnable_ready_true_when_real_profile_present(kanban_home, monkeypatch):
+ """``has_spawnable_ready`` returns True as soon as ANY ready task
+ has an assignee that maps to a real Hermes profile — preserves the
+ real "stuck" signal when a daily/agent task is queued."""
+ from hermes_cli import profiles
+ monkeypatch.setattr(
+ profiles, "profile_exists", lambda name: name == "daily"
+ )
+ with kb.connect() as conn:
+ kb.create_task(conn, title="terminal-task", assignee="orion-cc")
+ kb.create_task(conn, title="hermes-task", assignee="daily")
+ assert kb.has_spawnable_ready(conn) is True
+
+
+def test_has_spawnable_ready_false_on_empty_queue(kanban_home):
+ """Empty queue is the trivial false case — no ready tasks at all."""
+ with kb.connect() as conn:
+ assert kb.has_spawnable_ready(conn) is False
+
+
+def test_dispatcher_capacity_excludes_nonspawnable_guarded_and_profile_capped(
+ kanban_home, monkeypatch
+):
+ from hermes_cli import profiles
+
+ monkeypatch.setattr(
+ profiles, "profile_exists", lambda name: name in {"busy", "free"},
+ )
+ with kb.connect() as conn:
+ nonspawnable = kb.create_task(conn, title="terminal", assignee="orion-cc")
+ capped = kb.create_task(conn, title="busy-ready", assignee="busy")
+ guarded = kb.create_task(
+ conn, title="pr-open", assignee="free", workspace_kind="worktree"
+ )
+ dispatchable = kb.create_task(conn, title="real-work", assignee="free")
+ kb.add_comment(
+ conn,
+ guarded,
+ "worker",
+ "PR ready: https://github.com/NousResearch/hermes-agent/pull/123",
+ )
+ running = kb.create_task(conn, title="busy-running", assignee="busy")
+ with kb.write_txn(conn):
+ conn.execute(
+ "UPDATE tasks SET status='running' WHERE id=?",
+ (running,),
+ )
+
+ snapshot = kb.dispatcher_capacity_snapshot(
+ conn,
+ max_spawn=4,
+ max_in_progress_per_profile=1,
+ )
+
+ assert snapshot["free_global_slots"] == 3
+ assert snapshot["dispatchable_task_ids"] == [dispatchable]
+ assert nonspawnable not in snapshot["dispatchable_task_ids"]
+ assert capped not in snapshot["dispatchable_task_ids"]
+ assert guarded not in snapshot["dispatchable_task_ids"]
+
+
+def test_dispatcher_capacity_reports_no_work_when_global_slots_are_full(
+ kanban_home, all_assignees_spawnable
+):
+ with kb.connect() as conn:
+ kb.create_task(conn, title="waiting", assignee="worker")
+ running = kb.create_task(conn, title="running", assignee="worker")
+ with kb.write_txn(conn):
+ conn.execute("UPDATE tasks SET status='running' WHERE id=?", (running,))
+
+ snapshot = kb.dispatcher_capacity_snapshot(conn, max_spawn=1)
+
+ assert snapshot["free_global_slots"] == 0
+ assert snapshot["dispatchable_count"] == 0
+
+
+def test_dispatcher_capacity_applies_per_profile_cap_across_review_rows(
+ kanban_home, monkeypatch
+):
+ from hermes_cli import profiles
+
+ monkeypatch.setattr(profiles, "profile_exists", lambda name: name == "reviewer")
+ with kb.connect() as conn:
+ running = kb.create_task(conn, title="running", assignee="reviewer")
+ review = kb.create_task(conn, title="review", assignee="reviewer")
+ with kb.write_txn(conn):
+ conn.execute("UPDATE tasks SET status='running' WHERE id=?", (running,))
+ conn.execute("UPDATE tasks SET status='review' WHERE id=?", (review,))
+
+ snapshot = kb.dispatcher_capacity_snapshot(
+ conn, max_spawn=3, max_in_progress_per_profile=1
+ )
+ result = kb.dispatch_once(
+ conn, dry_run=True, max_spawn=3, max_in_progress_per_profile=1
+ )
+
+ assert review not in snapshot["dispatchable_task_ids"]
+ assert result.spawned == []
+ assert (review, "reviewer", 1) in result.skipped_per_profile_capped
+
+
+def test_dispatch_promotes_ready_and_spawns(kanban_home, all_assignees_spawnable):
+ spawns = []
+
+ def fake_spawn(task, workspace):
+ spawns.append((task.id, task.assignee, workspace))
+
+ with kb.connect() as conn:
+ p = kb.create_task(conn, title="p", assignee="alice")
+ c = kb.create_task(conn, title="c", assignee="bob", parents=[p])
+ # Finish parent outside dispatch; promotion happens inside.
+ kb.complete_task(conn, p)
+ res = kb.dispatch_once(conn, spawn_fn=fake_spawn)
+ # Spawned c (a was already done when dispatch was called).
+ assert len(spawns) == 1
+ assert spawns[0][0] == c
+ assert spawns[0][1] == "bob"
+ # c is now running
+ with kb.connect() as conn:
+ assert kb.get_task(conn, c).status == "running"
+
+
+def test_dispatch_spawn_failure_releases_claim(kanban_home, all_assignees_spawnable):
+ def boom(task, workspace):
+ raise RuntimeError("spawn failed")
+
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="boom", assignee="alice")
+ kb.dispatch_once(conn, spawn_fn=boom)
+ # Must return to ready so the next tick can retry.
+ assert kb.get_task(conn, t).status == "ready"
+ assert kb.get_task(conn, t).claim_lock is None
+
+
+def test_dispatch_max_spawn_counts_existing_running_tasks(
+ kanban_home, all_assignees_spawnable
+):
+ """max_spawn is a live concurrency cap, not a per-tick spawn cap.
+
+ Without counting tasks already in ``running``, every dispatcher tick can
+ launch up to ``max_spawn`` more workers while previous workers are still
+ alive. Long-running boards then accumulate unbounded worker subprocesses.
+ """
+ spawns = []
+
+ def fake_spawn(task, workspace):
+ spawns.append(task.id)
+
+ with kb.connect() as conn:
+ running_a = kb.create_task(conn, title="running-a", assignee="alice")
+ running_b = kb.create_task(conn, title="running-b", assignee="bob")
+ ready = kb.create_task(conn, title="ready", assignee="carol")
+ kb.claim_task(conn, running_a)
+ kb.claim_task(conn, running_b)
+
+ res = kb.dispatch_once(conn, spawn_fn=fake_spawn, max_spawn=2)
+
+ assert res.spawned == []
+ assert spawns == []
+ assert kb.get_task(conn, ready).status == "ready"
+
+
+def test_dispatch_max_spawn_fills_remaining_capacity(
+ kanban_home, all_assignees_spawnable
+):
+ """When below cap, dispatch only fills available worker slots."""
+ spawns = []
+
+ def fake_spawn(task, workspace):
+ spawns.append(task.id)
+
+ with kb.connect() as conn:
+ running = kb.create_task(conn, title="running", assignee="alice")
+ ready_a = kb.create_task(conn, title="ready-a", assignee="bob")
+ ready_b = kb.create_task(conn, title="ready-b", assignee="carol")
+ kb.claim_task(conn, running)
+
+ res = kb.dispatch_once(conn, spawn_fn=fake_spawn, max_spawn=2)
+
+ assert len(res.spawned) == 1
+ assert spawns == [ready_a]
+ assert kb.get_task(conn, ready_a).status == "running"
+ assert kb.get_task(conn, ready_b).status == "ready"
+
+
+def test_dispatch_reclaims_stale_before_spawning(kanban_home):
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="x", assignee="alice")
+ kb.claim_task(conn, t)
+ conn.execute(
+ "UPDATE tasks SET claim_expires = ? WHERE id = ?",
+ (int(time.time()) - 1, t),
+ )
+ res = kb.dispatch_once(conn, dry_run=True)
+ assert res.reclaimed == 1
# ---------------------------------------------------------------------------
# Respawn guard (check_respawn_guard + dispatch_once integration)
# ---------------------------------------------------------------------------
+def test_respawn_guard_none_on_fresh_task(kanban_home):
+ """A fresh task with no failures or runs is not guarded."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="fresh", assignee="alice")
+ reason = kb.check_respawn_guard(conn, t)
+ assert reason is None
+
+
+def test_respawn_guard_blocker_auth_on_quota_error(kanban_home):
+ """'quota' in last_failure_error triggers blocker_auth."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="quota-task", assignee="alice")
+ conn.execute(
+ "UPDATE tasks SET last_failure_error = ? WHERE id = ?",
+ ("API quota exceeded: rate limit hit", t),
+ )
+ reason = kb.check_respawn_guard(conn, t)
+ assert reason == "blocker_auth"
+
+
+def test_respawn_guard_blocker_auth_on_auth_error(kanban_home):
+ """'unauthorized' in last_failure_error triggers blocker_auth."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="auth-task", assignee="alice")
+ conn.execute(
+ "UPDATE tasks SET last_failure_error = ? WHERE id = ?",
+ ("403 Forbidden: unauthorized to access resource", t),
+ )
+ reason = kb.check_respawn_guard(conn, t)
+ assert reason == "blocker_auth"
+
+
+def test_respawn_guard_blocker_auth_on_authentication_error(kanban_home):
+ """Full word 'Authentication' triggers blocker_auth (regex covers auth\\w*)."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="authn-task", assignee="alice")
+ conn.execute(
+ "UPDATE tasks SET last_failure_error = ? WHERE id = ?",
+ ("Authentication failed: invalid credentials", t),
+ )
+ reason = kb.check_respawn_guard(conn, t)
+ assert reason == "blocker_auth"
+
+
+def test_respawn_guard_blocker_auth_on_authorization_error(kanban_home):
+ """Full word 'authorization' triggers blocker_auth (regex covers auth\\w*)."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="authz-task", assignee="alice")
+ conn.execute(
+ "UPDATE tasks SET last_failure_error = ? WHERE id = ?",
+ ("authorization denied for scope repo", t),
+ )
+ reason = kb.check_respawn_guard(conn, t)
+ assert reason == "blocker_auth"
+
+
+def test_respawn_guard_recent_success(kanban_home):
+ """A completed run within the guard window triggers recent_success."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="already-done", assignee="alice")
+ now = int(time.time())
+ conn.execute(
+ "INSERT INTO task_runs (task_id, status, outcome, started_at, ended_at) "
+ "VALUES (?, 'done', 'completed', ?, ?)",
+ (t, now - 120, now - 60),
+ )
+ reason = kb.check_respawn_guard(conn, t)
+ assert reason == "recent_success"
+
+
+def test_respawn_guard_recent_success_bypassed_by_requeue(kanban_home):
+ """An explicit re-queue after a recent success (operator done->ready,
+ promote, unblock, reclaim) is a deliberate re-run and must bypass the
+ recent_success guard — otherwise a manual done->ready just sits there
+ until the window elapses."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="rerun-me", assignee="alice")
+ now = int(time.time())
+ conn.execute(
+ "INSERT INTO task_runs (task_id, status, outcome, started_at, ended_at) "
+ "VALUES (?, 'done', 'completed', ?, ?)",
+ (t, now - 120, now - 60),
+ )
+ # Baseline: a recent completion defers the respawn.
+ assert kb.check_respawn_guard(conn, t) == "recent_success"
+ # Operator drags done -> ready: a 'status' event after completion.
+ conn.execute(
+ "INSERT INTO task_events (task_id, kind, created_at) "
+ "VALUES (?, 'status', ?)",
+ (t, now - 10),
+ )
+ assert kb.check_respawn_guard(conn, t) is None
+
+
+def test_respawn_guard_stale_success_not_guarded(kanban_home):
+ """A completed run outside the guard window does not block re-spawn."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="old-done", assignee="alice")
+ old_end = int(time.time()) - kb._RESPAWN_GUARD_SUCCESS_WINDOW - 60
+ conn.execute(
+ "INSERT INTO task_runs (task_id, status, outcome, started_at, ended_at) "
+ "VALUES (?, 'done', 'completed', ?, ?)",
+ (t, old_end - 300, old_end),
+ )
+ reason = kb.check_respawn_guard(conn, t)
+ assert reason is None
+
+
+def test_respawn_guard_active_pr_in_comment(kanban_home):
+ """A GitHub PR URL in a recent comment triggers active_pr."""
+ with kb.connect() as conn:
+ t = kb.create_task(
+ conn, title="has-pr", assignee="alice", workspace_kind="worktree"
+ )
+ kb.add_comment(
+ conn, t, "worker",
+ "PR created: https://github.com/totemx-AI/subsidysmart/pull/42",
+ )
+ reason = kb.check_respawn_guard(conn, t)
+ assert reason == "active_pr"
+
+
+def test_respawn_guard_ignores_pr_evidence_on_dir_task_without_branch(kanban_home):
+ """Non-code evidence tasks must not be blocked by cited PR URLs."""
+ with kb.connect() as conn:
+ t = kb.create_task(
+ conn, title="reconcile-evidence", assignee="alice", workspace_kind="dir"
+ )
+ kb.add_comment(
+ conn, t, "worker",
+ "Evidence: https://github.com/NousResearch/hermes-agent/pull/123",
+ )
+ assert kb.check_respawn_guard(conn, t) is None
+
+
+def test_respawn_guard_old_pr_comment_not_guarded(kanban_home):
+ """A GitHub PR URL in a comment older than the PR window does not block."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="old-pr", assignee="alice")
+ old_ts = int(time.time()) - kb._RESPAWN_GUARD_PR_WINDOW - 60
+ conn.execute(
+ "INSERT INTO task_comments (task_id, author, body, created_at) "
+ "VALUES (?, 'worker', "
+ "'PR: https://github.com/totemx-AI/subsidysmart/pull/10', ?)",
+ (t, old_ts),
+ )
+ reason = kb.check_respawn_guard(conn, t)
+ assert reason is None
+
+
+def test_dispatch_respawn_guard_defers_auth_error_without_auto_block(
+ kanban_home, all_assignees_spawnable
+):
+ """dispatch_once defers (does NOT auto-block) a ready task whose last
+ error is a blocker_auth.
+
+ The old behaviour auto-blocked on first occurrence, which was too
+ aggressive: a transient 429 rate-limit (which typically clears in
+ seconds to minutes) would end up requiring manual unblock. The new
+ behaviour defers the spawn this tick; the task stays in ``ready``
+ and gets another chance next tick. If the auth error genuinely
+ persists, the existing ``consecutive_failures`` circuit breaker
+ will auto-block via the normal failure-limit path.
+ """
+ spawned_ids = []
+
+ def fake_spawn(task, workspace):
+ spawned_ids.append(task.id)
+
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="quota-storm", assignee="alice")
+ conn.execute(
+ "UPDATE tasks SET last_failure_error = ? WHERE id = ?",
+ ("rate limit exceeded: 429 Too Many Requests", t),
+ )
+ res = kb.dispatch_once(conn, spawn_fn=fake_spawn)
+
+ # Critical: task is NOT auto-blocked on first occurrence.
+ assert t not in res.auto_blocked, (
+ f"blocker_auth should defer, not auto-block on first occurrence; "
+ f"got auto_blocked={res.auto_blocked!r}"
+ )
+ # It IS recorded as respawn_guarded with the reason.
+ assert (t, "blocker_auth") in res.respawn_guarded, (
+ f"expected (task_id, 'blocker_auth') in respawn_guarded; "
+ f"got {res.respawn_guarded!r}"
+ )
+ # And it's NOT spawned this tick.
+ assert t not in spawned_ids
+ # Status stays ``ready`` so a future tick (or operator action) can
+ # retry without manual unblock.
+ with kb.connect() as conn:
+ assert kb.get_task(conn, t).status == "ready"
+
+
+def test_dispatch_respawn_guard_skips_recent_success(
+ kanban_home, all_assignees_spawnable
+):
+ """dispatch_once skips (but does not block) a task with a recent completed run."""
+ spawned_ids = []
+
+ def fake_spawn(task, workspace):
+ spawned_ids.append(task.id)
+
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="recent-winner", assignee="alice")
+ now = int(time.time())
+ conn.execute(
+ "INSERT INTO task_runs (task_id, status, outcome, started_at, ended_at) "
+ "VALUES (?, 'done', 'completed', ?, ?)",
+ (t, now - 300, now - 60),
+ )
+ res = kb.dispatch_once(conn, spawn_fn=fake_spawn)
+
+ assert (t, "recent_success") in res.respawn_guarded
+ assert t not in spawned_ids
+ assert t not in res.auto_blocked
+ with kb.connect() as conn:
+ assert kb.get_task(conn, t).status == "ready" # not blocked, just skipped
+
+
+def test_dispatch_respawn_guard_skips_active_pr(
+ kanban_home, all_assignees_spawnable, tmp_path
+):
+ """dispatch_once skips (but does not block) a task with an active PR comment."""
+ spawned_ids = []
+
+ def fake_spawn(task, workspace):
+ spawned_ids.append(task.id)
+
+ repo = tmp_path / "repo"
+ _init_git_repo(repo)
+ with kb.connect() as conn:
+ t = kb.create_task(
+ conn, title="has-pr", assignee="alice", workspace_kind="worktree",
+ workspace_path=str(repo),
+ )
+ kb.add_comment(
+ conn, t, "worker",
+ "Opened https://github.com/totemx-AI/subsidysmart/pull/99",
+ )
+ res = kb.dispatch_once(conn, spawn_fn=fake_spawn)
+
+ assert (t, "active_pr") in res.respawn_guarded
+ assert t not in spawned_ids
+ assert t not in res.auto_blocked
+ with kb.connect() as conn:
+ assert kb.get_task(conn, t).status == "ready"
+
+
+def test_dispatch_respawn_guard_dry_run_no_auto_block(
+ kanban_home, all_assignees_spawnable
+):
+ """In dry_run mode, blocker_auth tasks are recorded in respawn_guarded (not auto-blocked)."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="dry-quota", assignee="alice")
+ conn.execute(
+ "UPDATE tasks SET last_failure_error = ? WHERE id = ?",
+ ("quota exceeded", t),
+ )
+ res = kb.dispatch_once(conn, dry_run=True)
+
+ assert (t, "blocker_auth") in res.respawn_guarded
+ assert t not in res.auto_blocked
+ with kb.connect() as conn:
+ assert kb.get_task(conn, t).status == "ready" # dry_run: no writes
+
+
+def test_dispatch_respawn_guard_allows_clean_task(
+ kanban_home, all_assignees_spawnable
+):
+ """A task with no guard triggers is spawned normally."""
+ spawned_ids = []
+
+ def fake_spawn(task, workspace):
+ spawned_ids.append(task.id)
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="clean-task", assignee="alice")
+ res = kb.dispatch_once(conn, spawn_fn=fake_spawn)
+ assert t in spawned_ids
+ assert not res.respawn_guarded
+ assert t not in res.auto_blocked
+def test_dispatch_respawn_guard_emits_event_for_skipped_task(
+ kanban_home, all_assignees_spawnable
+):
+ """dispatch_once emits a respawn_guarded task_event so operators can diagnose stuck-ready tasks."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="event-check", assignee="alice")
+ now = int(time.time())
+ conn.execute(
+ "INSERT INTO task_runs (task_id, status, outcome, started_at, ended_at) "
+ "VALUES (?, 'done', 'completed', ?, ?)",
+ (t, now - 300, now - 60),
+ )
+ kb.dispatch_once(conn, spawn_fn=lambda task, ws: None)
+ events = kb.list_events(conn, t)
+ kinds = [e.kind for e in events]
+ assert "respawn_guarded" in kinds
+ guarded_evt = next(e for e in events if e.kind == "respawn_guarded")
+ # Event.payload is already parsed as a dict by list_events.
+ assert isinstance(guarded_evt.payload, dict)
+ assert guarded_evt.payload.get("reason") == "recent_success"
# ---------------------------------------------------------------------------
# Workspace resolution
# ---------------------------------------------------------------------------
+def test_scratch_workspace_created_under_hermes_home(kanban_home):
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="x")
+ task = kb.get_task(conn, t)
+ assert task is not None
+ ws = kb.resolve_workspace(task)
+ assert ws.exists()
+ assert ws.is_dir()
+ assert "kanban" in str(ws)
+
+
+def test_dir_workspace_honors_given_path(kanban_home, tmp_path):
+ target = tmp_path / "my-vault"
+ with kb.connect() as conn:
+ t = kb.create_task(
+ conn, title="biz", workspace_kind="dir", workspace_path=str(target)
+ )
+ task = kb.get_task(conn, t)
+ assert task is not None
+ ws = kb.resolve_workspace(task)
+ assert ws == target
+ assert ws.exists()
+def test_worktree_workspace_repo_root_anchor_materializes_linked_worktree(kanban_home, tmp_path):
+ repo = tmp_path / "repo"
+ _init_git_repo(repo)
+ with kb.connect() as conn:
+ t = kb.create_task(
+ conn, title="ship", workspace_kind="worktree", workspace_path=str(repo)
+ )
+ task = kb.get_task(conn, t)
+ assert task is not None
+ ws = kb.resolve_workspace(task)
+ expected = repo / ".worktrees" / t
+ assert ws == expected
+ assert ws.exists()
+ repo_common = subprocess.run(
+ ["git", "-C", str(repo), "rev-parse", "--path-format=absolute", "--git-common-dir"],
+ check=True,
+ capture_output=True,
+ text=True,
+ ).stdout.strip()
+ ws_common = subprocess.run(
+ ["git", "-C", str(ws), "rev-parse", "--path-format=absolute", "--git-common-dir"],
+ check=True,
+ capture_output=True,
+ text=True,
+ ).stdout.strip()
+ assert ws_common == repo_common
+ listed = subprocess.run(
+ ["git", "-C", str(repo), "worktree", "list", "--porcelain"],
+ check=True,
+ capture_output=True,
+ text=True,
+ ).stdout
+ assert f"worktree {expected}" in listed
+ assert f"branch refs/heads/wt/{t}" in listed
+def test_worktree_no_path_anchors_on_board_default_workdir(kanban_home, tmp_path):
+ """A worktree task created with no explicit path inherits the board's
+ default_workdir as its anchor and materializes a per-task linked worktree
+ at ``/.worktrees/`` — NOT the dispatcher's CWD, and NOT the
+ shared default_workdir verbatim (which would collapse every task into one
+ directory)."""
+ repo = tmp_path / "repo"
+ _init_git_repo(repo)
+ kb.create_board("wt-default-board", default_workdir=str(repo))
+ with kb.connect(board="wt-default-board") as conn:
+ t = kb.create_task(
+ conn, title="ship", workspace_kind="worktree", board="wt-default-board"
+ )
+ task = kb.get_task(conn, t)
+ assert task is not None
+ ws = kb.resolve_workspace(task, board="wt-default-board")
+ expected = repo / ".worktrees" / t
+ assert ws == expected
+ assert ws.exists()
+ assert ws != repo # not the shared default verbatim
+
+
+def test_worktree_no_path_no_board_default_raises(kanban_home, tmp_path, monkeypatch):
+ """With neither an explicit workspace_path nor a board default_workdir,
+ resolution fails loudly pointing at default_workdir / worktree: —
+ rather than silently materializing under the dispatcher's CWD (the old
+ behavior that scattered worktrees under whatever dir launched the
+ gateway)."""
+ # Park the dispatcher CWD inside a real git repo so the OLD cwd-anchored
+ # code would have "succeeded" — proving the new code does NOT use cwd.
+ decoy_repo = tmp_path / "decoy"
+ _init_git_repo(decoy_repo)
+ monkeypatch.chdir(decoy_repo)
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="ship", workspace_kind="worktree")
+ task = kb.get_task(conn, t)
+ assert task is not None
+ with pytest.raises(ValueError, match="default_workdir"):
+ kb.resolve_workspace(task)
def test_worktree_workspace_explicit_target_materializes_linked_worktree(kanban_home, tmp_path):
@@ -706,10 +2492,120 @@ def test_worktree_workspace_explicit_target_materializes_linked_worktree(kanban_
assert f"branch refs/heads/{branch}" in listed
+def test_dispatch_worktree_task_persists_materialized_workspace_and_branch(kanban_home, tmp_path, monkeypatch):
+ repo = tmp_path / "repo"
+ _init_git_repo(repo)
+ kb.create_board("worktree-board", default_workdir=str(repo))
+ import hermes_cli.profiles as profiles
+ monkeypatch.setattr(profiles, "profile_exists", lambda _name: True)
+ spawns: list[tuple[str, str]] = []
+
+ def fake_spawn(task, workspace, board=None):
+ spawns.append((task.id, workspace))
+ return None
+
+ with kb.connect(board="worktree-board") as conn:
+ tid = kb.create_task(
+ conn,
+ title="ship",
+ assignee="sentinel",
+ workspace_kind="worktree",
+ board="worktree-board",
+ )
+ result = kb.dispatch_once(conn, spawn_fn=fake_spawn, board="worktree-board")
+ task = kb.get_task(conn, tid)
+
+ expected = repo / ".worktrees" / tid
+ assert result.spawned == [(tid, "sentinel", str(expected))]
+ assert spawns == [(tid, str(expected))]
+ assert task is not None
+ assert task.workspace_path == str(expected)
+ assert task.branch_name == f"wt/{tid}"
+ listed = subprocess.run(
+ ["git", "-C", str(repo), "worktree", "list", "--porcelain"],
+ check=True,
+ capture_output=True,
+ text=True,
+ ).stdout
+ assert f"worktree {expected}" in listed
+ assert f"branch refs/heads/wt/{tid}" in listed
+
+
+def test_dispatch_worktree_task_rerun_reuses_existing_linked_worktree_and_branch(kanban_home, tmp_path, monkeypatch):
+ repo = tmp_path / "repo"
+ _init_git_repo(repo)
+ kb.create_board("worktree-rerun-board", default_workdir=str(repo))
+ import hermes_cli.profiles as profiles
+ monkeypatch.setattr(profiles, "profile_exists", lambda _name: True)
+ spawns: list[tuple[str, str]] = []
+
+ def fake_spawn(task, workspace, board=None):
+ spawns.append((task.id, workspace))
+ return None
+
+ with kb.connect(board="worktree-rerun-board") as conn:
+ tid = kb.create_task(
+ conn,
+ title="ship",
+ assignee="sentinel",
+ workspace_kind="worktree",
+ board="worktree-rerun-board",
+ )
+ first = kb.dispatch_once(conn, spawn_fn=fake_spawn, board="worktree-rerun-board")
+ first_task = kb.get_task(conn, tid)
+ assert first_task is not None
+ expected = repo / ".worktrees" / tid
+ assert first_task.workspace_path == str(expected)
+ assert first_task.branch_name == f"wt/{tid}"
+
+ conn.execute(
+ "UPDATE tasks SET status='ready', claim_lock=NULL, claim_expires=NULL, worker_pid=NULL WHERE id=?",
+ (tid,),
+ )
+ conn.commit()
+
+ second = kb.dispatch_once(conn, spawn_fn=fake_spawn, board="worktree-rerun-board")
+ second_task = kb.get_task(conn, tid)
+
+ assert first.spawned == [(tid, "sentinel", str(expected))]
+ assert second.spawned == [(tid, "sentinel", str(expected))]
+ assert spawns == [(tid, str(expected)), (tid, str(expected))]
+ assert second_task is not None
+ assert second_task.workspace_path == str(expected)
+ actual_branch = subprocess.run(
+ ["git", "-C", str(expected), "branch", "--show-current"],
+ check=True,
+ capture_output=True,
+ text=True,
+ ).stdout.strip()
+ assert actual_branch == f"wt/{tid}"
+ assert second_task.branch_name == actual_branch
+ listed = subprocess.run(
+ ["git", "-C", str(repo), "worktree", "list", "--porcelain"],
+ check=True,
+ capture_output=True,
+ text=True,
+ ).stdout
+ assert listed.count(f"worktree {expected}\n") == 1
+ assert f"worktree {expected}/.worktrees/{tid}" not in listed
+ assert f"branch refs/heads/{actual_branch}" in listed
+
+
# ---------------------------------------------------------------------------
# Scratch cleanup containment (#28818)
# ---------------------------------------------------------------------------
+def test_cleanup_workspace_removes_managed_scratch_dir(kanban_home):
+ """A scratch workspace under the kanban workspaces root is removed."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="scratchy")
+ task = kb.get_task(conn, t)
+ assert task is not None
+ ws = kb.resolve_workspace(task)
+ kb.set_workspace_path(conn, t, ws)
+ assert ws.is_dir()
+ kb.complete_task(conn, t, result="ok")
+ assert not ws.exists(), "Hermes-managed scratch dir should be cleaned up"
def test_complete_task_persists_scratch_artifacts_before_cleanup(kanban_home):
@@ -748,13 +2644,252 @@ def test_complete_task_persists_scratch_artifacts_before_cleanup(kanban_home):
]
+def test_complete_task_rejects_missing_declared_scratch_artifact(kanban_home):
+ """A declared scratch deliverable must not disappear behind a false Done."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="missing report")
+ task = kb.get_task(conn, t)
+ ws = kb.resolve_workspace(task)
+ kb.set_workspace_path(conn, t, ws)
+ missing = ws / "report.md"
+
+ with pytest.raises(kb.ArtifactPreservationError, match="unavailable"):
+ kb.complete_task(
+ conn,
+ t,
+ result="report complete",
+ metadata={"artifacts": [str(missing)]},
+ )
+
+ assert kb.get_task(conn, t).status == "ready"
+ assert kb.list_attachments(conn, t) == []
+ assert ws.exists(), "failed completion must keep scratch available for retry"
+
+
+def test_complete_task_preserves_legacy_artifact_path_from_summary(kanban_home):
+ """Summary-only workers keep the file they tell the user was delivered."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="legacy report")
+ task = kb.get_task(conn, t)
+ ws = kb.resolve_workspace(task)
+ kb.set_workspace_path(conn, t, ws)
+ report = ws / "report.md"
+ report.write_text("legacy deliverable", encoding="utf-8")
+
+ assert kb.complete_task(
+ conn,
+ t,
+ summary=f"Task complete — delivered {report}",
+ )
+ run = kb.latest_run(conn, t)
+
+ persisted = Path(run.metadata["artifacts"][0])
+ assert not ws.exists()
+ assert persisted.read_text(encoding="utf-8") == "legacy deliverable"
+ assert persisted.parent == kb.task_attachments_dir(t)
+
+
+def test_complete_task_leaves_non_scratch_artifact_paths_unchanged(
+ kanban_home,
+ tmp_path,
+):
+ """Only artifacts inside the managed scratch workspace are copied."""
+ external = tmp_path / "report.md"
+ external.write_text("keep me here", encoding="utf-8")
+
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="external report")
+ task = kb.get_task(conn, t)
+ ws = kb.resolve_workspace(task)
+ kb.set_workspace_path(conn, t, ws)
+
+ assert kb.complete_task(
+ conn,
+ t,
+ result="ok",
+ metadata={"artifacts": [str(external)]},
+ )
+
+ completed = [e for e in kb.list_events(conn, t) if e.kind == "completed"][-1]
+ run = kb.latest_run(conn, t)
+
+ assert not ws.exists(), "scratch workspace should still be cleaned up"
+ assert external.exists()
+ assert completed.payload["artifacts"] == [str(external)]
+ assert run is not None
+ assert run.metadata["artifacts"] == [str(external)]
+
+
+def test_complete_task_persists_duplicate_scratch_artifact_names(kanban_home):
+ """Scratch artifact persistence does not overwrite duplicate basenames."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="render reports")
+ task = kb.get_task(conn, t)
+ ws = kb.resolve_workspace(task)
+ kb.set_workspace_path(conn, t, ws)
+ first = ws / "a" / "report.txt"
+ second = ws / "b" / "report.txt"
+ first.parent.mkdir(parents=True)
+ second.parent.mkdir(parents=True)
+ first.write_text("first", encoding="utf-8")
+ second.write_text("second", encoding="utf-8")
+
+ assert kb.complete_task(
+ conn,
+ t,
+ result="ok",
+ metadata={"artifacts": [str(first), str(second)]},
+ )
+
+ completed = [e for e in kb.list_events(conn, t) if e.kind == "completed"][-1]
+ persisted = [Path(p) for p in completed.payload["artifacts"]]
+
+ assert not ws.exists(), "scratch workspace should still be cleaned up"
+ assert [p.name for p in persisted] == ["report.txt", "report_1.txt"]
+ assert [p.read_text(encoding="utf-8") for p in persisted] == ["first", "second"]
+ assert all(p.parent == kb.task_attachments_dir(t) for p in persisted)
+
+
+def test_complete_task_persists_board_scratch_artifacts_to_board_attachments(kanban_home):
+ """Board scratch artifacts are copied under that board's attachment root."""
+ kb.create_board("work-proj")
+
+ with kb.connect(board="work-proj") as conn:
+ t = kb.create_task(conn, title="board chart", board="work-proj")
+ task = kb.get_task(conn, t)
+ ws = kb.resolve_workspace(task, board="work-proj")
+ kb.set_workspace_path(conn, t, ws)
+ artifact = ws / "chart.png"
+ artifact.write_bytes(b"board-png")
+
+ assert kb.complete_task(
+ conn,
+ t,
+ result="ok",
+ metadata={"artifacts": [str(artifact)]},
+ )
+
+ completed = [e for e in kb.list_events(conn, t) if e.kind == "completed"][-1]
+ persisted = Path(completed.payload["artifacts"][0])
+
+ assert not ws.exists(), "board scratch workspace should still be cleaned up"
+ assert persisted.exists()
+ assert persisted.parent == kb.task_attachments_dir(t, board="work-proj")
+
+
+def test_cleanup_workspace_refuses_path_outside_scratch_root(kanban_home, tmp_path):
+ """A scratch task with a user path outside the workspaces root must NOT be deleted (#28818).
+
+ Reproduces the data-loss vector where a board's ``default_workdir`` is set
+ to a real source directory; tasks created without an explicit
+ ``workspace_kind`` inherit ``scratch`` semantics, and the old cleanup path
+ would ``shutil.rmtree`` the user's source tree on task completion.
+ """
+ real_source = tmp_path / "real-source"
+ real_source.mkdir()
+ (real_source / ".git").mkdir()
+ (real_source / "README.md").write_text("important", encoding="utf-8")
+
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="ship")
+ # Simulate the bad state directly: workspace_kind='scratch' (default)
+ # but workspace_path pointing at the user's real source tree, which is
+ # exactly what board.default_workdir produces when the task is created
+ # without an explicit workspace_kind.
+ conn.execute(
+ "UPDATE tasks SET workspace_kind=?, workspace_path=? WHERE id=?",
+ ("scratch", str(real_source), t),
+ )
+ conn.commit()
+ kb.complete_task(conn, t, result="ok")
+
+ assert real_source.exists(), "User source tree must not be deleted by scratch cleanup"
+ assert (real_source / ".git").exists()
+ assert (real_source / "README.md").read_text(encoding="utf-8") == "important"
+
+
+def test_cleanup_workspace_honors_workspaces_root_env_override(tmp_path, monkeypatch):
+ """``HERMES_KANBAN_WORKSPACES_ROOT`` extends the managed-scratch set.
+
+ Worker subprocesses run with this env var injected by the dispatcher. The
+ cleanup containment check must treat paths under it as managed even when
+ they sit outside the active kanban home.
+ """
+ home = tmp_path / ".hermes"
+ home.mkdir()
+ monkeypatch.setenv("HERMES_HOME", str(home))
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ workspaces_override = tmp_path / "ext-workspaces"
+ workspaces_override.mkdir()
+ monkeypatch.setenv("HERMES_KANBAN_WORKSPACES_ROOT", str(workspaces_override))
+ kb.init_db()
+
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="ext")
+ scratch_dir = workspaces_override / t
+ scratch_dir.mkdir()
+ conn.execute(
+ "UPDATE tasks SET workspace_kind=?, workspace_path=? WHERE id=?",
+ ("scratch", str(scratch_dir), t),
+ )
+ conn.commit()
+ kb.complete_task(conn, t, result="ok")
+
+ assert not scratch_dir.exists(), "Override-root scratch dir should be cleaned up"
# ---------------------------------------------------------------------------
# Deferred scratch cleanup for parent/child handoff (#33774)
# ---------------------------------------------------------------------------
+def test_cleanup_workspace_deferred_while_child_active(kanban_home):
+ """A scratch parent's workspace survives completion while a child is still active.
+
+ The dependency chain (parents=[A]) must guarantee child B can read A's
+ handoff artifacts. The old cleanup deleted A's scratch dir immediately on
+ A's completion, before B ever ran.
+ """
+ with kb.connect() as conn:
+ parent = kb.create_task(conn, title="parent")
+ child = kb.create_task(conn, title="child")
+ kb.link_tasks(conn, parent, child) # child depends on parent
+ p_task = kb.get_task(conn, parent)
+ parent_ws = kb.resolve_workspace(p_task)
+ kb.set_workspace_path(conn, parent, parent_ws)
+ assert parent_ws.is_dir()
+ # Parent completes; child is still 'todo' -> cleanup must be deferred.
+ kb.complete_task(conn, parent, result="handoff written")
+
+ assert parent_ws.exists(), (
+ "Parent scratch workspace must survive while a linked child is active"
+ )
+
+
+def test_cleanup_workspace_swept_after_last_child_completes(kanban_home):
+ """Once all children are terminal, the deferred parent scratch dir is removed."""
+ with kb.connect() as conn:
+ parent = kb.create_task(conn, title="parent")
+ child = kb.create_task(conn, title="child")
+ kb.link_tasks(conn, parent, child)
+ p_task = kb.get_task(conn, parent)
+ parent_ws = kb.resolve_workspace(p_task)
+ kb.set_workspace_path(conn, parent, parent_ws)
+ # Give the child its own scratch dir too.
+ c_task = kb.get_task(conn, child)
+ child_ws = kb.resolve_workspace(c_task)
+ kb.set_workspace_path(conn, child, child_ws)
+
+ kb.complete_task(conn, parent, result="ok")
+ assert parent_ws.exists(), "deferred while child active"
+
+ # Child completes -> recompute promotes nothing new; the child's
+ # cleanup sweep should now reap the parent's deferred workspace.
+ kb.complete_task(conn, child, result="done")
+ assert not parent_ws.exists(), (
+ "Parent scratch workspace should be swept once all children are terminal"
+ )
+ assert not child_ws.exists(), "Child scratch workspace should be cleaned up too"
def test_dir_child_completion_unblocks_deferred_scratch_parent(kanban_home, tmp_path):
@@ -788,6 +2923,18 @@ def test_dir_child_completion_unblocks_deferred_scratch_parent(kanban_home, tmp_
assert child_dir.exists(), "Non-scratch 'dir' child workspace is never deleted"
+def test_is_managed_scratch_path_accepts_per_board_workspaces(kanban_home, tmp_path):
+ """Per-board scratch dirs under ``/kanban/boards//workspaces`` are managed."""
+ board_scratch = kanban_home / "kanban" / "boards" / "my-board" / "workspaces" / "task-1"
+ board_scratch.mkdir(parents=True)
+ assert kb._is_managed_scratch_path(board_scratch)
+
+
+def test_is_managed_scratch_path_rejects_real_source_tree(kanban_home, tmp_path):
+ """A path outside any managed root (e.g. a user's repo) is NOT managed."""
+ real = tmp_path / "code" / "my-project"
+ real.mkdir(parents=True)
+ assert not kb._is_managed_scratch_path(real)
def test_is_managed_scratch_path_rejects_kanban_metadata_subtrees(kanban_home):
@@ -835,21 +2982,135 @@ def test_is_managed_scratch_path_rejects_kanban_metadata_subtrees(kanban_home):
# Tenancy
# ---------------------------------------------------------------------------
+def test_tenant_column_filters_listings(kanban_home):
+ with kb.connect() as conn:
+ kb.create_task(conn, title="a1", tenant="biz-a")
+ kb.create_task(conn, title="b1", tenant="biz-b")
+ kb.create_task(conn, title="shared") # no tenant
+ biz_a = kb.list_tasks(conn, tenant="biz-a")
+ biz_b = kb.list_tasks(conn, tenant="biz-b")
+ assert [t.title for t in biz_a] == ["a1"]
+ assert [t.title for t in biz_b] == ["b1"]
+
+
+def test_list_tasks_filters_workflow_template_and_step(kanban_home):
+ with kb.connect() as conn:
+ ta = kb.create_task(conn, title="alpha")
+ tb = kb.create_task(conn, title="beta")
+ conn.execute(
+ "UPDATE tasks SET workflow_template_id=?, current_step_key=? WHERE id=?",
+ ("wf1", "step_x", ta),
+ )
+ conn.execute(
+ "UPDATE tasks SET workflow_template_id=?, current_step_key=? WHERE id=?",
+ ("wf1", "step_y", tb),
+ )
+ conn.commit()
+ by_wf = kb.list_tasks(conn, workflow_template_id="wf1")
+ by_step = kb.list_tasks(conn, current_step_key="step_x")
+ assert {x.id for x in by_wf} == {ta, tb}
+ assert [x.id for x in by_step] == [ta]
+def test_list_runs_state_filter_requires_pair_and_valid_type(kanban_home):
+ with kb.connect() as conn:
+ tid = kb.create_task(conn, title="t", assignee="alice")
+ with kb.connect() as conn:
+ with pytest.raises(ValueError, match="both"):
+ kb.list_runs(conn, tid, state_type="status", state_name=None)
+ with pytest.raises(ValueError, match="both"):
+ kb.list_runs(conn, tid, state_type=None, state_name="done")
+ with pytest.raises(ValueError, match="state_type"):
+ kb.list_runs(conn, tid, state_type="nope", state_name="done")
+def test_list_runs_filters_by_outcome_value(kanban_home):
+ with kb.connect() as conn:
+ tid = kb.create_task(conn, title="t", assignee="alice")
+ kb.complete_task(conn, tid, summary="ok")
+ matching = kb.list_runs(conn, tid, state_type="outcome", state_name="completed")
+ empty = kb.list_runs(conn, tid, state_type="outcome", state_name="blocked")
+ assert matching
+ assert not empty
+def test_tenant_propagates_to_events(kanban_home):
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="tenant-task", tenant="biz-a")
+ events = kb.list_events(conn, t)
+ # The "created" event should have tenant in its payload.
+ created = [e for e in events if e.kind == "created"]
+ assert created and created[0].payload.get("tenant") == "biz-a"
# ---------------------------------------------------------------------------
# Originating session id (ACP propagation)
# ---------------------------------------------------------------------------
+def test_create_task_stamps_session_id(kanban_home):
+ with kb.connect() as conn:
+ tid = kb.create_task(
+ conn, title="from chat", session_id="acp-sess-123"
+ )
+ t = kb.get_task(conn, tid)
+ assert t is not None
+ assert t.session_id == "acp-sess-123"
+
+
+def test_create_task_session_id_defaults_to_none(kanban_home):
+ with kb.connect() as conn:
+ tid = kb.create_task(conn, title="cli-created")
+ t = kb.get_task(conn, tid)
+ assert t is not None
+ assert t.session_id is None
+
+def test_session_id_filters_listings(kanban_home):
+ with kb.connect() as conn:
+ kb.create_task(conn, title="s1-a", session_id="sess-1")
+ kb.create_task(conn, title="s1-b", session_id="sess-1")
+ kb.create_task(conn, title="s2-a", session_id="sess-2")
+ kb.create_task(conn, title="cli-only") # no session
+ sess1 = kb.list_tasks(conn, session_id="sess-1")
+ sess2 = kb.list_tasks(conn, session_id="sess-2")
+ unscoped = kb.list_tasks(conn)
+ assert sorted(t.title for t in sess1) == ["s1-a", "s1-b"]
+ assert [t.title for t in sess2] == ["s2-a"]
+ # Unscoped list still returns everything (legacy NULL rows visible).
+ assert len(unscoped) == 4
+
+
+def test_session_id_index_exists(kanban_home):
+ """The migration creates an index on session_id for cheap per-session
+ list queries on busy boards. Without it, a chat-scoped poll would
+ full-scan the tasks table."""
+ with kb.connect() as conn:
+ rows = conn.execute(
+ "SELECT name FROM sqlite_master WHERE type='index' "
+ "AND tbl_name='tasks'"
+ ).fetchall()
+ names = {r["name"] for r in rows}
+ assert "idx_tasks_session_id" in names
+def test_session_id_compose_with_tenant_filter(kanban_home):
+ """A client may want both `tenant=scarf:foo` AND `session=acp-x` —
+ the filters must AND, not replace."""
+ with kb.connect() as conn:
+ kb.create_task(
+ conn, title="match", tenant="scarf:foo", session_id="acp-x"
+ )
+ kb.create_task(
+ conn, title="wrong-tenant", tenant="other", session_id="acp-x"
+ )
+ kb.create_task(
+ conn, title="wrong-session",
+ tenant="scarf:foo", session_id="acp-y",
+ )
+ rows = kb.list_tasks(
+ conn, tenant="scarf:foo", session_id="acp-x"
+ )
+ assert [t.title for t in rows] == ["match"]
# ---------------------------------------------------------------------------
@@ -871,6 +3132,21 @@ def _set_home(self, monkeypatch, tmp_path, hermes_home):
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("HERMES_KANBAN_HOME", raising=False)
+ def test_default_install_anchors_at_home_dot_hermes(
+ self, tmp_path, monkeypatch
+ ):
+ # Standard install: HERMES_HOME == ~/.hermes, no profile active.
+ default_home = tmp_path / ".hermes"
+ default_home.mkdir()
+ self._set_home(monkeypatch, tmp_path, default_home)
+
+ assert kb.kanban_home() == default_home
+ assert kb.kanban_db_path() == default_home / "kanban.db"
+ assert kb.workspaces_root() == default_home / "kanban" / "workspaces"
+ assert (
+ kb.worker_log_path("t_demo")
+ == default_home / "kanban" / "logs" / "t_demo.log"
+ )
def test_profile_worker_resolves_to_shared_root(
self, tmp_path, monkeypatch
@@ -895,14 +3171,93 @@ def test_profile_worker_resolves_to_shared_root(
== default_home / "kanban" / "logs" / "t_0d214f19.log"
)
- # Sanity: the profile-local path that used to be returned is
- # explicitly NOT what we resolve to anymore.
- assert kb.kanban_db_path() != profile_home / "kanban.db"
+ # Sanity: the profile-local path that used to be returned is
+ # explicitly NOT what we resolve to anymore.
+ assert kb.kanban_db_path() != profile_home / "kanban.db"
+
+ def test_dispatcher_and_profile_worker_converge(
+ self, tmp_path, monkeypatch
+ ):
+ # End-to-end convergence: resolve the path under each side's
+ # HERMES_HOME and confirm equality. This is the property the
+ # dispatcher/worker handoff actually depends on.
+ default_home = tmp_path / ".hermes"
+ default_home.mkdir()
+ profile_home = default_home / "profiles" / "coder"
+ profile_home.mkdir(parents=True)
+
+ # Dispatcher's perspective.
+ self._set_home(monkeypatch, tmp_path, default_home)
+ dispatcher_db = kb.kanban_db_path()
+ dispatcher_ws = kb.workspaces_root()
+ dispatcher_log = kb.worker_log_path("t_handoff")
+
+ # Worker's perspective (profile activated by `hermes -p coder`).
+ monkeypatch.setenv("HERMES_HOME", str(profile_home))
+ worker_db = kb.kanban_db_path()
+ worker_ws = kb.workspaces_root()
+ worker_log = kb.worker_log_path("t_handoff")
+
+ assert dispatcher_db == worker_db
+ assert dispatcher_ws == worker_ws
+ assert dispatcher_log == worker_log
+ def test_docker_custom_hermes_home_uses_env_path_directly(
+ self, tmp_path, monkeypatch
+ ):
+ # Docker / custom deployment: HERMES_HOME points outside ~/.hermes.
+ # `get_default_hermes_root()` returns env_home directly when it
+ # is not a `/profiles/` shape and not under
+ # `Path.home() / ".hermes"`.
+ custom_root = tmp_path / "opt" / "hermes"
+ custom_root.mkdir(parents=True)
+ self._set_home(monkeypatch, tmp_path, custom_root)
+
+ assert kb.kanban_home() == custom_root
+ assert kb.kanban_db_path() == custom_root / "kanban.db"
+
+ def test_docker_profile_layout_uses_grandparent(
+ self, tmp_path, monkeypatch
+ ):
+ # Docker profile shape: HERMES_HOME=/opt/hermes/profiles/coder;
+ # `get_default_hermes_root()` walks up to /opt/hermes because
+ # the immediate parent dir is named "profiles".
+ custom_root = tmp_path / "opt" / "hermes"
+ profile = custom_root / "profiles" / "coder"
+ profile.mkdir(parents=True)
+ self._set_home(monkeypatch, tmp_path, profile)
+
+ assert kb.kanban_home() == custom_root
+ assert kb.kanban_db_path() == custom_root / "kanban.db"
+
+ def test_explicit_override_via_hermes_kanban_home(
+ self, tmp_path, monkeypatch
+ ):
+ # Explicit override: HERMES_KANBAN_HOME beats every other
+ # resolution rule.
+ default_home = tmp_path / ".hermes"
+ profile_home = default_home / "profiles" / "any"
+ profile_home.mkdir(parents=True)
+ override = tmp_path / "shared-board"
+ override.mkdir()
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ monkeypatch.setenv("HERMES_HOME", str(profile_home))
+ monkeypatch.setenv("HERMES_KANBAN_HOME", str(override))
+ assert kb.kanban_home() == override
+ assert kb.kanban_db_path() == override / "kanban.db"
+ assert kb.workspaces_root() == override / "kanban" / "workspaces"
+ def test_empty_override_falls_through(self, tmp_path, monkeypatch):
+ # Empty/whitespace override is treated as unset.
+ default_home = tmp_path / ".hermes"
+ default_home.mkdir()
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ monkeypatch.setenv("HERMES_HOME", str(default_home))
+ monkeypatch.setenv("HERMES_KANBAN_HOME", " ")
+ assert kb.kanban_home() == default_home
def test_dispatcher_and_worker_share_a_real_database(
self, tmp_path, monkeypatch
@@ -928,8 +3283,63 @@ def test_dispatcher_and_worker_share_a_real_database(
assert task is not None
assert task.title == "cross-profile"
+ def test_hermes_kanban_db_pin_beats_kanban_home(
+ self, tmp_path, monkeypatch
+ ):
+ # HERMES_KANBAN_DB pins the file path directly and beats both
+ # HERMES_KANBAN_HOME and the `get_default_hermes_root()` path.
+ # This is the env the dispatcher injects into workers.
+ default_home = tmp_path / ".hermes"
+ default_home.mkdir()
+ umbrella = tmp_path / "umbrella"
+ umbrella.mkdir()
+ pinned_db = tmp_path / "pinned" / "board.db"
+ pinned_db.parent.mkdir()
+
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ monkeypatch.setenv("HERMES_HOME", str(default_home))
+ monkeypatch.setenv("HERMES_KANBAN_HOME", str(umbrella))
+ monkeypatch.setenv("HERMES_KANBAN_DB", str(pinned_db))
+
+ assert kb.kanban_db_path() == pinned_db
+ # workspaces_root still follows HERMES_KANBAN_HOME -- the pins
+ # are independent.
+ assert kb.workspaces_root() == umbrella / "kanban" / "workspaces"
+
+ def test_hermes_kanban_workspaces_root_pin_beats_kanban_home(
+ self, tmp_path, monkeypatch
+ ):
+ # HERMES_KANBAN_WORKSPACES_ROOT pins the workspaces root directly.
+ default_home = tmp_path / ".hermes"
+ default_home.mkdir()
+ umbrella = tmp_path / "umbrella"
+ umbrella.mkdir()
+ pinned_ws = tmp_path / "pinned-workspaces"
+ pinned_ws.mkdir()
+
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ monkeypatch.setenv("HERMES_HOME", str(default_home))
+ monkeypatch.setenv("HERMES_KANBAN_HOME", str(umbrella))
+ monkeypatch.setenv("HERMES_KANBAN_WORKSPACES_ROOT", str(pinned_ws))
+
+ assert kb.workspaces_root() == pinned_ws
+ # kanban_db_path still follows HERMES_KANBAN_HOME.
+ assert kb.kanban_db_path() == umbrella / "kanban.db"
+ def test_empty_per_path_overrides_fall_through(
+ self, tmp_path, monkeypatch
+ ):
+ # Empty/whitespace pins are treated as unset, same as
+ # HERMES_KANBAN_HOME.
+ default_home = tmp_path / ".hermes"
+ default_home.mkdir()
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ monkeypatch.setenv("HERMES_HOME", str(default_home))
+ monkeypatch.setenv("HERMES_KANBAN_DB", " ")
+ monkeypatch.setenv("HERMES_KANBAN_WORKSPACES_ROOT", "")
+ assert kb.kanban_db_path() == default_home / "kanban.db"
+ assert kb.workspaces_root() == default_home / "kanban" / "workspaces"
def test_dispatcher_spawn_injects_kanban_paths_without_stale_session(
self, tmp_path, monkeypatch
@@ -993,10 +3403,77 @@ def __init__(self, cmd, **kwargs):
# latest_summary / latest_summaries — surface task_runs.summary handoffs
# ---------------------------------------------------------------------------
+def test_latest_summary_returns_none_when_no_runs(kanban_home):
+ """A freshly-created task has no runs and therefore no summary."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="fresh", assignee="alice")
+ assert kb.latest_summary(conn, t) is None
+
+
+def test_latest_summary_returns_summary_after_complete(kanban_home):
+ """``complete_task(summary=...)`` is the canonical kanban-worker
+ handoff; ``latest_summary`` must surface it so dashboards/CLI can
+ render what the worker actually did."""
+ handoff = "shipped 3 files, ran tests, opened PR #42"
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="work", assignee="alice")
+ kb.complete_task(conn, t, summary=handoff)
+ assert kb.latest_summary(conn, t) == handoff
+
+
+def test_latest_summary_picks_newest_when_multiple_runs(kanban_home):
+ """When a task has been re-run (block → unblock → complete), the
+ newest run's summary wins. We unblock to take the task back to
+ ``ready``, then complete a second time and verify the second
+ summary surfaces."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="retry", assignee="alice")
+ kb.complete_task(conn, t, summary="first attempt")
+ # Move back to ready by direct SQL — block_task / unblock_task
+ # paths require an active claim, but we just want a second run
+ # row to exist with a later ended_at.
+ conn.execute(
+ "UPDATE tasks SET status='ready', completed_at=NULL WHERE id=?",
+ (t,),
+ )
+ # Sleep 1s so the second run's ended_at is provably later than
+ # the first (complete_task uses int(time.time())).
+ time.sleep(1.05)
+ kb.complete_task(conn, t, summary="second attempt — final")
+ assert kb.latest_summary(conn, t) == "second attempt — final"
+def test_latest_summary_skips_empty_string(kanban_home):
+ """A run with an empty-string summary should not mask an earlier
+ populated one — empty strings carry no information."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="t", assignee="alice")
+ kb.complete_task(conn, t, summary="real handoff")
+ # Inject a later run with empty summary directly. Workers
+ # writing "" instead of None is a real shape we want to ignore.
+ conn.execute(
+ "INSERT INTO task_runs (task_id, status, started_at, ended_at, "
+ "outcome, summary) VALUES (?, 'done', ?, ?, 'completed', ?)",
+ (t, int(time.time()) + 1, int(time.time()) + 2, ""),
+ )
+ conn.commit()
+ assert kb.latest_summary(conn, t) == "real handoff"
+def test_latest_summaries_batch_omits_tasks_without_summary(kanban_home):
+ """``latest_summaries`` is the dashboard's N+1 escape hatch — it
+ must return only entries for tasks that actually have a summary,
+ keep the per-task latest, and accept an empty input gracefully."""
+ with kb.connect() as conn:
+ t1 = kb.create_task(conn, title="a", assignee="alice")
+ t2 = kb.create_task(conn, title="b", assignee="bob")
+ t3 = kb.create_task(conn, title="c", assignee="carol")
+ kb.complete_task(conn, t1, summary="alpha")
+ kb.complete_task(conn, t3, summary="charlie")
+ out = kb.latest_summaries(conn, [t1, t2, t3])
+ assert out == {t1: "alpha", t3: "charlie"}
+ # Empty input → empty dict, no SQL syntax error from "IN ()".
+ assert kb.latest_summaries(conn, []) == {}
@@ -1024,23 +3501,36 @@ def test_connect_falls_back_to_delete_on_locking_protocol(tmp_path, monkeypatch,
import sqlite3 as _sqlite3
from unittest.mock import patch as _patch
+ import hermes_state as _hs
+
+ # This test exercises the WAL-attempt path (locking-protocol fallback),
+ # which is a different code path from the WAL-reset vulnerability guard
+ # in _apply_delete_for_wal_reset_bug. Force is_sqlite_wal_reset_vulnerable
+ # False so a vulnerable linked SQLite on the CI runner doesn't
+ # short-circuit to DELETE (and only a WARNING) before ever reaching the
+ # journal_mode=WAL pragma that _WalBlockingConnection intercepts below —
+ # without this, the test is SQLite-build-dependent instead of
+ # deterministic (passes locally on a non-vulnerable build, fails on CI's).
+ monkeypatch.setattr(
+ _hs, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False,
+ )
+
+ # The fallback warning is deduped process-globally ("once per process per
+ # database" — _log_wal_fallback_once / _log_wal_reset_bug_once). Any earlier
+ # test in this file that opened a kanban.db already consumed the one-shot
+ # for that label, so without clearing it this test sees zero warnings and
+ # fails only when run as part of the file (it passes in isolation). Clear
+ # both dedup sets so the warning is emitted for this connect().
+ _hs._wal_fallback_warned_paths.clear()
+ _hs._wal_reset_bug_warned_paths.clear()
+
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
- # These tests exercise the WAL-attempt path; assume a fixed SQLite so the
- # WAL-reset vulnerability gate doesn't short-circuit before the pragma.
- import hermes_state as _hermes_state
- monkeypatch.setattr(
- _hermes_state, "is_sqlite_wal_reset_vulnerable",
- lambda version_info=None: False,
- )
- _hermes_state._wal_fallback_warned_paths.clear()
-
# Clear module cache so a fresh connect() is attempted
kb._INITIALIZED_PATHS.clear()
- hermes_state._wal_fallback_warned_paths.clear()
real_connect = _sqlite3.connect
@@ -1052,8 +3542,7 @@ def execute(self, sql, *args, **kwargs): # type: ignore[override]
def wal_blocking_connect(*args, **kwargs):
# connect_tracked passes a tracking-augmented factory; drop it and
- # substitute the double, which connect_tracked re-applies to the
- # returned instance.
+ # substitute the double, which connect_tracked will re-augment.
kwargs.pop("factory", None)
return real_connect(
*args, factory=_WalBlockingConnection, **kwargs
@@ -1085,6 +3574,8 @@ def test_connect_works_when_wal_is_silently_refused(tmp_path, monkeypatch, caplo
import sqlite3 as _sqlite3
from unittest.mock import patch as _patch
+ import hermes_state
+
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
@@ -1169,6 +3660,25 @@ def test_unlink_tasks_triggers_recompute_ready(kanban_home):
)
+def test_archive_task_triggers_recompute_ready_for_dependents(kanban_home):
+ """Archiving a parent must immediately unblock its children.
+
+ ``recompute_ready()`` already treats ``archived`` parents as satisfied
+ dependencies, just like ``done``. Regression: ``archive_task()`` updated
+ the parent row but never ran the ready-promotion pass, so children stayed
+ stuck in ``todo`` until a later dispatcher tick.
+ """
+ with kb.connect() as conn:
+ parent = kb.create_task(conn, title="obsolete parent")
+ child = kb.create_task(conn, title="child", parents=[parent])
+
+ assert kb.get_task(conn, child).status == "todo"
+ assert kb.archive_task(conn, parent) is True
+
+ assert kb.get_task(conn, child).status == "ready", (
+ "child should promote to ready immediately after its last blocking "
+ "parent is archived"
+ )
# ---------------------------------------------------------------------------
# _add_column_if_missing / _migrate_add_optional_columns idempotency (#21708)
@@ -1270,6 +3780,118 @@ def test_migrate_add_optional_columns_tolerates_concurrent_migration(kanban_home
# ---------------------------------------------------------------------------
+def test_resolve_hermes_argv_prefers_path_shim(monkeypatch):
+ """When `hermes` is on PATH, use the shim — preserves familiar ps output."""
+ import shutil
+ import hermes_cli.kanban_db as kb
+
+ monkeypatch.delenv("HERMES_BIN", raising=False)
+ monkeypatch.setattr(shutil, "which", lambda name: "/usr/local/bin/hermes")
+ argv = kb._resolve_hermes_argv()
+ assert argv == ["/usr/local/bin/hermes"]
+
+
+def test_resolve_hermes_argv_absolutizes_relative_exe_shim(monkeypatch, tmp_path):
+ """A relative executable override must not remain workspace-cwd-dependent."""
+ import hermes_cli.kanban_db as kb
+
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("HERMES_BIN", ".\\hermes.exe")
+ monkeypatch.setattr(kb, "_IS_WINDOWS", True)
+
+ assert kb._resolve_hermes_argv() == [os.path.abspath(".\\hermes.exe")]
+
+
+def test_resolve_hermes_argv_avoids_implicit_windows_batch_shim(monkeypatch, tmp_path):
+ """Implicit .cmd/.bat shims use the module fallback, not batch argv[0]."""
+ import sys
+ import hermes_cli.kanban_db as kb
+
+ bin_dir = tmp_path / "bin"
+ bin_dir.mkdir()
+ (bin_dir / "hermes.CMD").write_text("@echo off\n", encoding="utf-8")
+ monkeypatch.delenv("HERMES_BIN", raising=False)
+ monkeypatch.setenv("PATH", str(bin_dir))
+ monkeypatch.setenv("PATHEXT", ".CMD")
+ monkeypatch.setattr(kb, "_IS_WINDOWS", True)
+
+ assert kb._resolve_hermes_argv() == [sys.executable, "-m", "hermes_cli.main"]
+
+
+def test_resolve_hermes_argv_honors_hermes_bin_path_override(monkeypatch, tmp_path):
+ """An explicit path-like HERMES_BIN lets service managers pin the executable."""
+ import shutil
+ import hermes_cli.kanban_db as kb
+
+ shim = tmp_path / "bin" / "hermes"
+ shim.parent.mkdir()
+ shim.write_text("#!/bin/sh\n", encoding="utf-8")
+ monkeypatch.setenv("HERMES_BIN", str(shim))
+ monkeypatch.setattr(shutil, "which", lambda name: None)
+
+ assert kb._resolve_hermes_argv() == [str(shim)]
+
+
+def test_resolve_hermes_argv_hermes_bin_bare_name_uses_path(monkeypatch, tmp_path):
+ """Bare HERMES_BIN values keep PATH semantics instead of cwd shadowing."""
+ import stat
+ import hermes_cli.kanban_db as kb
+
+ cwd_hermes = tmp_path / "hermes"
+ cwd_hermes.write_text("wrong\n", encoding="utf-8")
+ cwd_hermes.chmod(cwd_hermes.stat().st_mode | stat.S_IXUSR)
+ path_hermes = tmp_path / "bin" / "hermes"
+ path_hermes.parent.mkdir()
+ path_hermes.write_text("right\n", encoding="utf-8")
+ path_hermes.chmod(path_hermes.stat().st_mode | stat.S_IXUSR)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("PATH", str(path_hermes.parent))
+ monkeypatch.setenv("HERMES_BIN", "hermes")
+
+ assert kb._resolve_hermes_argv() == [str(path_hermes)]
+
+
+def test_resolve_hermes_argv_hermes_bin_bare_name_ignores_cwd(monkeypatch, tmp_path):
+ """Bare HERMES_BIN does not accept current-directory shadow executables."""
+ import sys
+ import hermes_cli.kanban_db as kb
+
+ (tmp_path / "hermes.exe").write_text("wrong\n", encoding="utf-8")
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("PATH", "")
+ monkeypatch.setenv("HERMES_BIN", "hermes")
+ monkeypatch.setattr(kb, "_IS_WINDOWS", True)
+
+ assert kb._resolve_hermes_argv() == [sys.executable, "-m", "hermes_cli.main"]
+
+
+def test_resolve_hermes_argv_hermes_bin_bare_cmd_uses_module_fallback(monkeypatch, tmp_path):
+ """A PATH-resolved HERMES_BIN batch shim is not used as worker argv[0]."""
+ import sys
+ import hermes_cli.kanban_db as kb
+
+ bin_dir = tmp_path / "bin"
+ bin_dir.mkdir()
+ (bin_dir / "hermes.CMD").write_text("@echo off\n", encoding="utf-8")
+ monkeypatch.setenv("PATH", str(bin_dir))
+ monkeypatch.setenv("PATHEXT", ".CMD")
+ monkeypatch.setenv("HERMES_BIN", "hermes")
+ monkeypatch.setattr(kb, "_IS_WINDOWS", True)
+
+ assert kb._resolve_hermes_argv() == [sys.executable, "-m", "hermes_cli.main"]
+
+
+def test_resolve_hermes_argv_hermes_bin_unresolved_bare_name_falls_back(monkeypatch):
+ """Unresolved HERMES_BIN command names do not delegate cwd search to Popen."""
+ import sys
+ import hermes_cli.kanban_db as kb
+
+ monkeypatch.setenv("PATH", "")
+ monkeypatch.setenv("HERMES_BIN", "hermes")
+
+ assert kb._resolve_hermes_argv() == [sys.executable, "-m", "hermes_cli.main"]
+
+
def test_resolve_hermes_argv_falls_back_to_module_form_when_no_path_shim(monkeypatch):
"""When the shim is not on PATH, fall back to `python -m hermes_cli.main`.
@@ -1352,47 +3974,597 @@ def _make_task(**overrides) -> "kb.Task":
return kb.Task(**defaults)
+def test_safe_int_accepts_int_and_int_string():
+ """Sanity: well-typed values pass through."""
+ # PR d8ad431de renamed _safe_int → _to_epoch (now also handles ISO-8601).
+ assert kb._to_epoch(0) == 0
+ assert kb._to_epoch(1700000000) == 1700000000
+ assert kb._to_epoch("1700000000") == 1700000000
+
+
+def test_safe_int_returns_none_on_corrupt_inputs():
+ """All the failure modes that used to crash task_age."""
+ # None — common when the column was never written
+ assert kb._to_epoch(None) is None
+ # Unsubstituted format string — the literal case the PR title cites
+ assert kb._to_epoch("%s") is None
+ # Arbitrary non-numeric strings
+ assert kb._to_epoch("abc") is None
+ assert kb._to_epoch("") is None
+ # Float-ish strings: int("1.5") raises ValueError too — caller wants None.
+ assert kb._to_epoch("1.5") is None
+ # Random object — covered by TypeError branch
+ assert kb._to_epoch(object()) is None
+
+
+def test_task_age_handles_corrupt_created_at():
+ """Pre-fix this raised ValueError and 500'd /api/plugins/kanban/board."""
+ t = _make_task(created_at="%s")
+ age = kb.task_age(t)
+ assert age["created_age_seconds"] is None
+ assert age["started_age_seconds"] is None
+ assert age["time_to_complete_seconds"] is None
+
+
+def test_task_age_handles_corrupt_started_and_completed():
+ """All three timestamp fields share the same _safe_int treatment."""
+ t = _make_task(
+ created_at=1700000000,
+ started_at="garbage",
+ completed_at=None,
+ )
+ age = kb.task_age(t)
+ assert isinstance(age["created_age_seconds"], int)
+ assert age["started_age_seconds"] is None
+ assert age["time_to_complete_seconds"] is None
+
+
+def test_task_age_well_formed_task():
+ """Regression: the safe-int path must not change behavior for normal data."""
+ import time
+ now = int(time.time())
+ t = _make_task(
+ created_at=now - 60,
+ started_at=now - 30,
+ completed_at=now,
+ )
+ age = kb.task_age(t)
+ assert 55 <= age["created_age_seconds"] <= 65
+ assert 25 <= age["started_age_seconds"] <= 35
+ assert 25 <= age["time_to_complete_seconds"] <= 35
+
+
+def test_task_dict_survives_corrupt_created_at(tmp_path, monkeypatch):
+ """Defense in depth: even if task_age ever raised, plugin_api must not 500.
+
+ The PR also added a try/except around the task_age call in
+ `plugins/kanban/dashboard/plugin_api.py::_task_dict`. Verify a single
+ corrupt row doesn't turn the whole board response into an error.
+ """
+ # Set up an isolated kanban home so we can write a corrupt created_at.
+ home = tmp_path / ".hermes"
+ home.mkdir()
+ monkeypatch.setenv("HERMES_HOME", str(home))
+ monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
+ kb._INITIALIZED_PATHS.clear()
+ kb.init_db()
+
+ # Insert a row with a non-int created_at (simulates the historical
+ # bug that produced corrupt rows).
+ conn = kb.connect()
+ try:
+ good_id = kb.create_task(conn, title="good")
+ # Now write a row with corrupt created_at directly.
+ conn.execute(
+ "UPDATE tasks SET created_at = ? WHERE id = ?",
+ ("%s", good_id),
+ )
+ finally:
+ conn.close()
+
+ # Re-read and pass through task_age — must not raise.
+ conn = kb.connect()
+ try:
+ task = kb.get_task(conn, good_id)
+ finally:
+ conn.close()
+ age = kb.task_age(task)
+ assert age["created_age_seconds"] is None
+
+
+# ---------------------------------------------------------------------------
+# Board-level default_workdir
+# ---------------------------------------------------------------------------
+
+
+def test_create_task_scratch_without_workspace_ignores_board_default_workdir(kanban_home, monkeypatch):
+ """Scratch tasks must NOT inherit board.default_workdir — would point auto-cleanup
+ at the user's source tree on completion (#28818)."""
+ default_wd = "/home/user/project"
+ kb.create_board("work-proj", default_workdir=default_wd)
+
+ with kb.connect(board="work-proj") as conn:
+ tid = kb.create_task(conn, title="scratch-task", board="work-proj")
+ t = kb.get_task(conn, tid)
+ assert t is not None
+ assert t.workspace_kind == "scratch"
+ assert t.workspace_path is None
+
+
+def test_create_task_dir_without_workspace_inherits_board_default_workdir(kanban_home, monkeypatch):
+ """Board default_workdir is for persistent dir/worktree workspaces, not scratch."""
+ default_wd = "/home/user/project"
+ kb.create_board("work-proj-dir", default_workdir=default_wd)
+
+ with kb.connect(board="work-proj-dir") as conn:
+ tid = kb.create_task(
+ conn,
+ title="inherited",
+ workspace_kind="dir",
+ board="work-proj-dir",
+ )
+ t = kb.get_task(conn, tid)
+ assert t is not None
+ assert t.workspace_path == default_wd
+
+
+def test_create_task_without_workspace_no_default_stays_none(kanban_home):
+ """Board without default_workdir → create_task without workspace_path → stays None."""
+ kb.create_board("empty-board")
+
+ with kb.connect(board="empty-board") as conn:
+ tid = kb.create_task(conn, title="none", board="empty-board")
+ t = kb.get_task(conn, tid)
+ assert t is not None
+ assert t.workspace_path is None
+
+
+def test_create_task_with_explicit_workspace_ignores_board_default(kanban_home):
+ """create_task with explicit workspace_path → ignores board default."""
+ kb.create_board("custom-ws-board", default_workdir="/board/default")
+
+ explicit = "/my/explicit/path"
+ with kb.connect(board="custom-ws-board") as conn:
+ tid = kb.create_task(conn, title="explicit", workspace_path=explicit, board="custom-ws-board")
+ t = kb.get_task(conn, tid)
+ assert t is not None
+ assert t.workspace_path == explicit
+ assert t.workspace_path != "/board/default"
+
+
+# ---------------------------------------------------------------------------
+# dispatch_once — max_in_progress
+# ---------------------------------------------------------------------------
+
+
+def test_dispatch_max_in_progress_skips_when_at_limit(kanban_home, all_assignees_spawnable):
+ """When max_in_progress=N and N tasks are already running, spawn nothing."""
+ spawns = []
+
+ def fake_spawn(task, workspace):
+ spawns.append(task.id)
+
+ with kb.connect() as conn:
+ # Two running tasks.
+ t1 = kb.create_task(conn, title="a", assignee="alice")
+ t2 = kb.create_task(conn, title="b", assignee="bob")
+ kb.claim_task(conn, t1)
+ kb.claim_task(conn, t2)
+ # Two more ready to spawn — but cap is 2 so none should fire.
+ kb.create_task(conn, title="c", assignee="bob")
+ kb.create_task(conn, title="d", assignee="alice")
+ kb.dispatch_once(conn, spawn_fn=fake_spawn, max_in_progress=2)
+
+ assert len(spawns) == 0, f"expected 0 spawns, got {len(spawns)}"
+
+
+def test_dispatch_max_in_progress_spawns_up_to_cap(kanban_home, all_assignees_spawnable):
+ """When max_in_progress=3 and only 1 is running, spawn up to 2 more."""
+ spawns = []
+
+ def fake_spawn(task, workspace):
+ spawns.append(task.id)
+
+ with kb.connect() as conn:
+ # One running task.
+ t1 = kb.create_task(conn, title="a", assignee="alice")
+ kb.claim_task(conn, t1)
+ # Three ready tasks — only the first 2 should be spawned.
+ kb.create_task(conn, title="b", assignee="bob")
+ kb.create_task(conn, title="c", assignee="bob")
+ kb.create_task(conn, title="d", assignee="bob")
+ kb.dispatch_once(conn, spawn_fn=fake_spawn, max_in_progress=3)
+
+ assert len(spawns) == 2, f"expected 2 spawns (cap 3 - 1 running), got {len(spawns)}"
+
+
+def test_dispatch_max_in_progress_none_is_unlimited(kanban_home, all_assignees_spawnable):
+ """Default None means no limit — all ready tasks are spawned."""
+ spawns = []
+
+ def fake_spawn(task, workspace):
+ spawns.append(task.id)
+
+ with kb.connect() as conn:
+ for title in ["a", "b", "c", "d"]:
+ kb.create_task(conn, title=title, assignee="alice")
+ kb.dispatch_once(conn, spawn_fn=fake_spawn, max_in_progress=None)
+
+ assert len(spawns) == 4, f"expected 4 spawns (unlimited), got {len(spawns)}"
+
+# Review column dispatch
+# ---------------------------------------------------------------------------
+
+
+def _set_task_status(conn: sqlite3.Connection, task_id: str, status: str) -> None:
+ """Test helper: set a task's status directly."""
+ conn.execute("UPDATE tasks SET status = ? WHERE id = ?", (status, task_id))
+
+
+def test_claim_review_task_transitions_to_running(kanban_home):
+ """claim_review_task atomically transitions review -> running."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="review me", assignee="alice")
+ _set_task_status(conn, t, "review")
+ claimed = kb.claim_review_task(conn, t)
+ assert claimed is not None
+ assert claimed.status == "running"
+ assert claimed.claim_lock is not None
+
+
+def test_claim_review_task_fails_on_non_review(kanban_home):
+ """claim_review_task returns None if task is not in review status."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="ready task", assignee="alice")
+ # Task is in 'ready', not 'review'
+ claimed = kb.claim_review_task(conn, t)
+ assert claimed is None
+
+
+def test_claim_review_task_fails_when_already_claimed(kanban_home):
+ """claim_review_task returns None if the task was already claimed."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="review me", assignee="alice")
+ _set_task_status(conn, t, "review")
+ first = kb.claim_review_task(conn, t)
+ assert first is not None
+ second = kb.claim_review_task(conn, t)
+ assert second is None
+
+
+def test_dispatch_review_dry_run(kanban_home, all_assignees_spawnable):
+ """dispatch_once dry-run sees review tasks and reports them as spawned."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="review me", assignee="alice")
+ _set_task_status(conn, t, "review")
+ res = kb.dispatch_once(conn, dry_run=True)
+ assert len(res.spawned) == 1
+ assert res.spawned[0][0] == t
+ # Dry run must NOT mutate status.
+ with kb.connect() as conn:
+ assert kb.get_task(conn, t).status == "review"
+
+
+def test_dispatch_review_spawns_with_correct_skills(
+ kanban_home, all_assignees_spawnable,
+):
+ """Review tasks get sdlc-review skill set before spawning."""
+ spawned_tasks = []
+
+ def capture_spawn(task, workspace, board=None):
+ spawned_tasks.append(task)
+ return 42 # fake PID
+
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="review me", assignee="alice")
+ _set_task_status(conn, t, "review")
+ res = kb.dispatch_once(conn, spawn_fn=capture_spawn)
+ assert len(res.spawned) == 1
+ assert len(spawned_tasks) == 1
+ assert spawned_tasks[0].skills == ["sdlc-review"]
+
+
+def test_dispatch_review_skips_unassigned(kanban_home):
+ """Unassigned review tasks go to skipped_unassigned, not spawned."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="review floater")
+ _set_task_status(conn, t, "review")
+ res = kb.dispatch_once(conn, dry_run=True)
+ assert t in res.skipped_unassigned
+ assert not res.spawned
+
+
+def test_dispatch_review_counts_toward_max_spawn(
+ kanban_home, all_assignees_spawnable,
+):
+ """Review spawns count against max_spawn alongside ready tasks."""
+ spawns = []
+
+ def fake_spawn(task, workspace, board=None):
+ spawns.append(task.id)
+ return 42
+
+ with kb.connect() as conn:
+ # Create 2 ready tasks + 1 review task, max_spawn=2
+ t1 = kb.create_task(conn, title="ready 1", assignee="alice")
+ t2 = kb.create_task(conn, title="ready 2", assignee="bob")
+ t3 = kb.create_task(conn, title="review", assignee="alice")
+ _set_task_status(conn, t3, "review")
+ res = kb.dispatch_once(conn, spawn_fn=fake_spawn, max_spawn=2)
+ # Only 2 should spawn (ready tasks get priority in the loop)
+ assert len(res.spawned) == 2
+ assert len(spawns) == 2
+
+
+def test_dispatch_review_spawns_when_ready_empty(
+ kanban_home, all_assignees_spawnable,
+):
+ """When only review tasks exist, they still get dispatched."""
+ spawns = []
+
+ def fake_spawn(task, workspace, board=None):
+ spawns.append(task.id)
+ return 42
+
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="review me", assignee="alice")
+ _set_task_status(conn, t, "review")
+ res = kb.dispatch_once(conn, spawn_fn=fake_spawn)
+ assert len(res.spawned) == 1
+ assert spawns[0] == t
+
+
+def test_has_spawnable_review_true(kanban_home):
+ """has_spawnable_review returns True when review tasks exist with real profiles."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="review me", assignee="default")
+ _set_task_status(conn, t, "review")
+ # default profile should exist in the test env
+ assert kb.has_spawnable_review(conn) is True
+
+
+def test_has_spawnable_review_false_on_empty(kanban_home):
+ """has_spawnable_review returns False when no review tasks exist."""
+ with kb.connect() as conn:
+ assert kb.has_spawnable_review(conn) is False
+
+
+def test_has_spawnable_review_false_when_only_terminal_lanes(
+ kanban_home, monkeypatch,
+):
+ """has_spawnable_review returns False when review tasks are terminal lanes."""
+ from hermes_cli import profiles
+ monkeypatch.setattr(profiles, "profile_exists", lambda name: False)
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="review", assignee="orion-cc")
+ _set_task_status(conn, t, "review")
+ assert kb.has_spawnable_review(conn) is False
+
+
+def test_dispatch_review_skips_nonspawnable(kanban_home, monkeypatch):
+ """Review tasks with non-existent profiles go to skipped_nonspawnable."""
+ from hermes_cli import profiles
+ monkeypatch.setattr(profiles, "profile_exists", lambda name: False)
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="review", assignee="orion-cc")
+ _set_task_status(conn, t, "review")
+ res = kb.dispatch_once(conn, dry_run=True)
+ assert t in res.skipped_nonspawnable
+ assert not res.spawned
+
+
+def test_review_status_in_valid_statuses():
+ """'review' is a valid task status."""
+ assert "review" in kb.VALID_STATUSES
+
+
+def test_dispatch_review_does_not_claim_ready_tasks(
+ kanban_home, all_assignees_spawnable,
+):
+ """Review dispatch uses claim_review_task, which only claims review tasks."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="ready task", assignee="alice")
+ # claim_review_task should NOT claim a ready task
+ claimed = kb.claim_review_task(conn, t)
+ assert claimed is None
+
+# Stale detection — detect_stale_running + HEL-3135 failure-counter wiring
+# ---------------------------------------------------------------------------
+
+def test_detect_stale_returns_running_task_with_no_heartbeat(kanban_home, monkeypatch):
+ """A task running > timeout with zero heartbeats gets reclaimed as stale."""
+ import hermes_cli.kanban_db as _kb
+
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="stale-no-hb", assignee="worker")
+ kb.claim_task(conn, t)
+ kb._set_worker_pid(conn, t, os.getpid())
+
+ # Rewind started_at so the task appears to have been running for 5 hours.
+ five_hours_ago = int(time.time()) - (5 * 3600)
+ with kb.write_txn(conn):
+ conn.execute(
+ "UPDATE tasks SET started_at = ? WHERE id = ?", (five_hours_ago, t)
+ )
+ conn.execute(
+ "UPDATE task_runs SET started_at = ? "
+ "WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)",
+ (five_hours_ago, t),
+ )
+ # No heartbeat set — last_heartbeat_at stays NULL.
+ monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: False)
+ killed = []
+ stale = kb.detect_stale_running(
+ conn, stale_timeout_seconds=14400, signal_fn=lambda p, s: killed.append(s),
+ )
+ assert t in stale, "Task with no heartbeat for >4h should be reclaimed"
+ task = kb.get_task(conn, t)
+ assert task.status == "ready"
+def test_detect_stale_returns_task_with_stale_heartbeat(kanban_home, monkeypatch):
+ """A task running > timeout with a heartbeat older than 1h gets reclaimed."""
+ import hermes_cli.kanban_db as _kb
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="stale-hb", assignee="worker")
+ kb.claim_task(conn, t)
+ kb._set_worker_pid(conn, t, os.getpid())
+ five_hours_ago = int(time.time()) - (5 * 3600)
+ heartbeat_2h_ago = int(time.time()) - (2 * 3600)
+ with kb.write_txn(conn):
+ conn.execute(
+ "UPDATE tasks SET started_at = ?, last_heartbeat_at = ? "
+ "WHERE id = ?",
+ (five_hours_ago, heartbeat_2h_ago, t),
+ )
+ conn.execute(
+ "UPDATE task_runs SET started_at = ? "
+ "WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)",
+ (five_hours_ago, t),
+ )
+ monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: False)
+ stale = kb.detect_stale_running(
+ conn, stale_timeout_seconds=14400, signal_fn=lambda p, s: None,
+ )
+ assert t in stale, (
+ "Task with heartbeat >1h old and started >4h ago should be stale"
+ )
+ assert kb.get_task(conn, t).status == "ready"
+def test_detect_stale_skips_task_with_recent_heartbeat(kanban_home, monkeypatch):
+ """A task running > timeout but with a recent heartbeat is NOT reclaimed."""
+ import hermes_cli.kanban_db as _kb
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="alive-hb", assignee="worker")
+ kb.claim_task(conn, t)
+ kb._set_worker_pid(conn, t, os.getpid())
-# ---------------------------------------------------------------------------
-# Board-level default_workdir
-# ---------------------------------------------------------------------------
+ five_hours_ago = int(time.time()) - (5 * 3600)
+ heartbeat_now = int(time.time()) # heartbeat just happened
+ with kb.write_txn(conn):
+ conn.execute(
+ "UPDATE tasks SET started_at = ?, last_heartbeat_at = ? "
+ "WHERE id = ?",
+ (five_hours_ago, heartbeat_now, t),
+ )
+ conn.execute(
+ "UPDATE task_runs SET started_at = ? "
+ "WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)",
+ (five_hours_ago, t),
+ )
+ monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: True)
+ stale = kb.detect_stale_running(
+ conn, stale_timeout_seconds=14400, signal_fn=lambda p, s: None,
+ )
+ assert stale == [], "Task with recent heartbeat should not be reclaimed"
+ assert kb.get_task(conn, t).status == "running"
+def test_detect_stale_skips_recently_started_task(kanban_home, monkeypatch):
+ """A task started < timeout ago is NOT reclaimed even with no heartbeat."""
+ import hermes_cli.kanban_db as _kb
-# ---------------------------------------------------------------------------
-# dispatch_once — max_in_progress
-# ---------------------------------------------------------------------------
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="fresh", assignee="worker")
+ kb.claim_task(conn, t)
+ kb._set_worker_pid(conn, t, os.getpid())
+ # Started only 1 hour ago — well within the 4h threshold.
+ one_hour_ago = int(time.time()) - 3600
+ with kb.write_txn(conn):
+ conn.execute(
+ "UPDATE tasks SET started_at = ? WHERE id = ?", (one_hour_ago, t)
+ )
+ conn.execute(
+ "UPDATE task_runs SET started_at = ? "
+ "WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)",
+ (one_hour_ago, t),
+ )
-# Review column dispatch
-# ---------------------------------------------------------------------------
+ monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: True)
+ stale = kb.detect_stale_running(
+ conn, stale_timeout_seconds=14400, signal_fn=lambda p, s: None,
+ )
+ assert stale == [], "Task started <4h ago should not be reclaimed"
+ assert kb.get_task(conn, t).status == "running"
-def _set_task_status(conn: sqlite3.Connection, task_id: str, status: str) -> None:
- """Test helper: set a task's status directly."""
- conn.execute("UPDATE tasks SET status = ? WHERE id = ?", (status, task_id))
+def test_detect_stale_skips_when_timeout_zero(kanban_home, monkeypatch):
+ """stale_timeout_seconds=0 disables stale detection entirely."""
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="disabled", assignee="worker")
+ kb.claim_task(conn, t)
+ kb._set_worker_pid(conn, t, os.getpid())
+ five_hours_ago = int(time.time()) - (5 * 3600)
+ with kb.write_txn(conn):
+ conn.execute(
+ "UPDATE tasks SET started_at = ? WHERE id = ?", (five_hours_ago, t)
+ )
+ conn.execute(
+ "UPDATE task_runs SET started_at = ? "
+ "WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)",
+ (five_hours_ago, t),
+ )
+ stale = kb.detect_stale_running(
+ conn, stale_timeout_seconds=0, signal_fn=lambda p, s: None,
+ )
+ assert stale == [], "timeout=0 should disable stale detection"
+ assert kb.get_task(conn, t).status == "running"
+def test_detect_stale_skips_blocked_tasks(kanban_home, monkeypatch):
+ """Blocked tasks are NOT reclaimed by stale detection."""
+ import hermes_cli.kanban_db as _kb
+ with kb.connect() as conn:
+ t = kb.create_task(conn, title="blocked-task", assignee="worker")
+ kb.claim_task(conn, t)
+ kb._set_worker_pid(conn, t, os.getpid())
+ five_hours_ago = int(time.time()) - (5 * 3600)
+ with kb.write_txn(conn):
+ conn.execute(
+ "UPDATE tasks SET started_at = ? WHERE id = ?", (five_hours_ago, t)
+ )
+ conn.execute(
+ "UPDATE task_runs SET started_at = ? "
+ "WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)",
+ (five_hours_ago, t),
+ )
+ # Block the task explicitly.
+ kb.block_task(conn, t, reason="human requested block")
-# Stale detection — detect_stale_running + HEL-3135 failure-counter wiring
-# ---------------------------------------------------------------------------
+ monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: False)
+ stale = kb.detect_stale_running(
+ conn, stale_timeout_seconds=14400, signal_fn=lambda p, s: None,
+ )
+ assert stale == [], "Blocked task should not be reclaimed by stale detection"
+ assert kb.get_task(conn, t).status == "blocked"
+# NOTE: test_detect_stale_does_not_tick_failure_counter (this PR's original
+# assertion that stale reclaim must NEVER tick consecutive_failures) is
+# superseded by fork/main's HEL-3135 fix below. Production incident
+# t_8cea8385 showed the opposite failure mode — a single stuck no-heartbeat
+# card re-dispatching 65 times because the counter never ticked. main's
+# detect_stale_running now calls _record_task_failure on every no-heartbeat
+# reclaim so repeated stale reclaims trip the same circuit breaker as
+# crashed/timed_out (see test_detect_stale_running_repeated_reclaims_trip_failure_limit
+# below), while a single reclaim still resets on a subsequent clean complete
+# (test_single_stale_reclaim_then_success_resets_failure_counter). The merged
+# hermes_cli/kanban_db.py already carries main's detect_stale_running
+# unmodified (auto-merged, no conflict) — this test file edit only aligns
+# the test suite with that already-resolved production behavior.
def _prepare_expired_claim(
conn: sqlite3.Connection,
*,
@@ -1637,6 +4809,33 @@ def _write_corrupt_db(path: Path) -> bytes:
return blob
+def test_init_db_refuses_corrupt_existing_file(tmp_path):
+ db_path = tmp_path / "kanban.db"
+ original = _write_corrupt_db(db_path)
+ # Ensure the cache doesn't mask the guard.
+ kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
+
+ with pytest.raises(kb.KanbanDbCorruptError) as excinfo:
+ kb.init_db(db_path=db_path)
+
+ err = excinfo.value
+ assert err.db_path == db_path
+ assert err.backup_path is not None
+ assert err.backup_path.exists()
+ assert err.backup_path.read_bytes() == original
+ # Original bytes untouched — no schema was written on top.
+ assert db_path.read_bytes() == original
+ assert str(db_path) in str(err)
+ assert str(err.backup_path) in str(err)
+
+
+def test_connect_refuses_corrupt_existing_file(tmp_path):
+ db_path = tmp_path / "kanban.db"
+ _write_corrupt_db(db_path)
+ kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
+
+ with pytest.raises(kb.KanbanDbCorruptError):
+ kb.connect(db_path=db_path)
def test_repeated_corrupt_open_reuses_single_backup(tmp_path):
@@ -1708,6 +4907,19 @@ def flaky_connect(*args, **kwargs):
assert "still here" in titles
+def test_init_db_allows_missing_then_healthy(tmp_path):
+ db_path = tmp_path / "fresh.db"
+ assert not db_path.exists()
+ kb.init_db(db_path=db_path)
+ assert db_path.exists() and db_path.stat().st_size > 0
+
+ # Idempotent on a healthy DB: data survives a second init.
+ with kb.connect(db_path=db_path) as conn:
+ kb.create_task(conn, title="keeps")
+ kb.init_db(db_path=db_path)
+ with kb.connect(db_path=db_path) as conn:
+ tasks = kb.list_tasks(conn)
+ assert [t.title for t in tasks] == ["keeps"]
# ---------------------------------------------------------------------------
@@ -1782,6 +4994,37 @@ def test_maybe_emit_scratch_tip_fires_once_per_install(kanban_home, caplog):
)
+def test_maybe_emit_scratch_tip_skips_non_scratch_workspaces(kanban_home, caplog):
+ """worktree/dir workspaces are preserved on completion and must not
+ trigger the scratch-cleanup tip."""
+ import logging
+
+ with kb.connect() as conn:
+ t_wt = kb.create_task(conn, title="worktree task")
+ t_dir = kb.create_task(conn, title="dir task")
+
+ assert not kb._scratch_tip_shown()
+
+ with caplog.at_level(logging.WARNING, logger="hermes_cli.kanban_db"):
+ with kb.connect() as conn:
+ kb._maybe_emit_scratch_tip(conn, t_wt, "worktree")
+ kb._maybe_emit_scratch_tip(conn, t_dir, "dir")
+
+ # Sentinel stays unset — these workspaces are preserved by design,
+ # so the warning is irrelevant for them and we save the one-shot
+ # for a real scratch user.
+ assert not kb._scratch_tip_shown()
+ tip_records = [
+ r for r in caplog.records
+ if "scratch workspaces are ephemeral" in r.getMessage()
+ ]
+ assert tip_records == []
+ with kb.connect() as conn:
+ for tid in (t_wt, t_dir):
+ events = conn.execute(
+ "SELECT kind FROM task_events WHERE task_id = ?", (tid,),
+ ).fetchall()
+ assert "tip_scratch_workspace" not in [e["kind"] for e in events]
# ---------------------------------------------------------------------------
@@ -1798,8 +5041,54 @@ def test_connect_sets_secure_delete_on(tmp_path):
assert row[0] == 1, f"expected secure_delete=1, got {row[0]}"
+def test_connect_sets_cell_size_check_on(tmp_path):
+ """cell_size_check=ON must be active on every new connection."""
+ db_path = tmp_path / "kanban.db"
+ kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
+ with kb.connect(db_path=db_path) as conn:
+ row = conn.execute("PRAGMA cell_size_check").fetchone()
+ assert row[0] == 1, f"expected cell_size_check=1, got {row[0]}"
+
+
+def test_connect_sets_synchronous_full(tmp_path):
+ """synchronous must be FULL (=2), not NORMAL (=1)."""
+ db_path = tmp_path / "kanban.db"
+ kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
+ with kb.connect(db_path=db_path) as conn:
+ row = conn.execute("PRAGMA synchronous").fetchone()
+ assert row[0] == 2, f"expected synchronous=2 (FULL), got {row[0]}"
+
+
+def test_connect_pragmas_applied_on_reconnect(tmp_path):
+ """All three pragmas must be re-applied on every connect(), not just the first."""
+ db_path = tmp_path / "kanban.db"
+ kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
+ # First connection: write a task and close.
+ with kb.connect(db_path=db_path) as conn:
+ kb.create_task(conn, title="reconnect-check")
+ # Force re-init path by discarding path cache.
+ kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
+ # Second connection: pragmas must still be applied.
+ with kb.connect(db_path=db_path) as conn:
+ assert conn.execute("PRAGMA secure_delete").fetchone()[0] == 1
+ assert conn.execute("PRAGMA cell_size_check").fetchone()[0] == 1
+ assert conn.execute("PRAGMA synchronous").fetchone()[0] == 2
+
+def test_pragmas_not_accidentally_disabled_by_migrate_path(tmp_path):
+ """Migration path must not reset connection pragmas."""
+ db_path = tmp_path / "legacy.db"
+ kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
+ # Initialise with a fresh connect so schema + init run.
+ with kb.connect(db_path=db_path) as conn:
+ kb.create_task(conn, title="pre-migration-task")
+ # Simulate a re-entry through the init/migration path by discarding path cache.
+ kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
+ with kb.connect(db_path=db_path) as conn:
+ assert conn.execute("PRAGMA secure_delete").fetchone()[0] == 1
+ assert conn.execute("PRAGMA cell_size_check").fetchone()[0] == 1
+ assert conn.execute("PRAGMA synchronous").fetchone()[0] == 2
# write_txn — rollback handler must not mask the original exception
# ---------------------------------------------------------------------------
@@ -1858,6 +5147,89 @@ def __getattr__(self, name):
f"write_txn surfaced the rollback failure instead of the original "
f"OperationalError; got {msg!r}"
)
+def test_write_txn_healthy_commit_no_exception(tmp_path):
+ """Normal commit does not trigger the torn-extend check."""
+ from hermes_cli.kanban_db import connect, write_txn
+ db = tmp_path / "test.db"
+ conn = connect(db_path=db)
+ # Should not raise
+ with write_txn(conn) as c:
+ c.execute(
+ "INSERT INTO tasks (id, title, assignee, status, priority, created_at) "
+ "VALUES ('t_test01', 'test task', 'tester', 'todo', 0, 1234567890)"
+ )
+ row = conn.execute("SELECT title FROM tasks WHERE id='t_test01'").fetchone()
+ assert row["title"] == "test task"
+ conn.close()
+
+
+def test_write_txn_raises_on_truncated_file(tmp_path):
+ """A mocked smaller file size triggers the torn-extend check.
+
+ The check now reads the header side via ``PRAGMA page_count`` over the
+ existing connection instead of ``open()``-ing the database file (an
+ open/close would cancel this process's POSIX locks). The on-disk side is
+ still ``stat()``, so that is what this test fakes. The invariant only
+ applies under a rollback journal — in WAL a committed page may still be
+ in the -wal file, so the main file legitimately lags.
+ """
+ from hermes_cli.kanban_db import connect, write_txn
+ db = tmp_path / "test.db"
+ conn = connect(db_path=db)
+ conn.execute("PRAGMA journal_mode=DELETE")
+ page_size = conn.execute("PRAGMA page_size").fetchone()[0]
+ original_getsize = os.path.getsize
+
+ def fake_getsize(path):
+ # Return a size that implies at least 1 fewer page than header claims
+ real_size = original_getsize(path)
+ return max(0, real_size - page_size)
+
+ with pytest.raises(sqlite3.DatabaseError, match="torn-extend|page count mismatch"):
+ with unittest.mock.patch(
+ "hermes_cli.sqlite_safe_read.os.path.getsize", side_effect=fake_getsize
+ ):
+ with write_txn(conn) as c:
+ c.execute(
+ "INSERT INTO tasks (id, title, assignee, status, priority, created_at) "
+ "VALUES ('t_test02', 'test task 2', 'tester', 'todo', 0, 1234567890)"
+ )
+ conn.close()
+
+
+def test_write_txn_post_commit_check_fires_every_call(tmp_path):
+ """The invariant check runs on every write_txn call."""
+ from hermes_cli.kanban_db import connect, write_txn
+ import hermes_cli.kanban_db as kanban_db_module
+ db = tmp_path / "test.db"
+ conn = connect(db_path=db)
+ call_count = 0
+ real_check = kanban_db_module._check_file_length_invariant
+
+ def counting_check(c):
+ nonlocal call_count
+ call_count += 1
+ real_check(c)
+
+ with unittest.mock.patch.object(kanban_db_module, "_check_file_length_invariant", counting_check):
+ for i in range(3):
+ with write_txn(conn) as c:
+ c.execute(
+ f"INSERT INTO tasks (id, title, assignee, status, priority, created_at) "
+ f"VALUES ('t_fire{i:02d}', 'task {i}', 'tester', 'todo', 0, 1234567890)"
+ )
+ assert call_count == 3
+ conn.close()
+
+
+def test_connect_sets_wal_autocheckpoint_100(tmp_path):
+ """connect() sets wal_autocheckpoint to 100."""
+ from hermes_cli.kanban_db import connect
+ db = tmp_path / "test.db"
+ conn = connect(db_path=db)
+ val = conn.execute("PRAGMA wal_autocheckpoint").fetchone()[0]
+ assert val == 100
+ conn.close()
def test_write_txn_check_reads_correct_header_fields(tmp_path):
@@ -1901,11 +5273,153 @@ def test_write_txn_check_reads_correct_header_fields(tmp_path):
# ---------------------------------------------------------------------------
+def test_reap_worker_zombies_returns_count():
+ """reap_worker_zombies() returns the list of reaped PIDs."""
+ from unittest.mock import patch
+
+ fake_pids = [12345, 67890, 11111]
+ call_count = [0]
+
+ def fake_waitpid(pid, flags):
+ if call_count[0] < len(fake_pids):
+ p = fake_pids[call_count[0]]
+ call_count[0] += 1
+ return p, 0
+ return 0, 0
+
+ with patch("hermes_cli.kanban_db.os.waitpid", side_effect=fake_waitpid):
+ with patch("hermes_cli.kanban_db._record_worker_exit"):
+ pids = kb.reap_worker_zombies()
+ assert pids == [12345, 67890, 11111]
+
+
+def test_reap_worker_zombies_noop_on_windows(monkeypatch):
+ """reap_worker_zombies() returns 0 and never calls os.waitpid on Windows."""
+ from unittest.mock import patch
+
+ monkeypatch.setattr("hermes_cli.kanban_db.os.name", "nt")
+ with patch("hermes_cli.kanban_db.os.waitpid") as mock_waitpid:
+ result = kb.reap_worker_zombies()
+ mock_waitpid.assert_not_called()
+ assert result == []
+
+
+def test_reap_worker_zombies_noop_no_children():
+ """reap_worker_zombies() returns 0 without error when there are no children."""
+ from unittest.mock import patch
+
+ with patch("hermes_cli.kanban_db.os.waitpid", side_effect=ChildProcessError):
+ result = kb.reap_worker_zombies()
+ assert result == []
+
+
+def test_reap_worker_zombies_records_exit_status():
+ """reap_worker_zombies() calls _record_worker_exit for each reaped pid."""
+ from unittest.mock import patch
+
+ calls = []
+ call_count = [0]
+
+ def fake_waitpid(pid, flags):
+ call_count[0] += 1
+ if call_count[0] == 1:
+ return 12345, 0
+ return 0, 0
+
+ with patch("hermes_cli.kanban_db.os.waitpid", side_effect=fake_waitpid):
+ with patch(
+ "hermes_cli.kanban_db._record_worker_exit",
+ side_effect=lambda p, s: calls.append((p, s)),
+ ):
+ kb.reap_worker_zombies()
+
+ assert calls == [(12345, 0)]
+
+
+def test_reap_worker_zombies_handles_waitpid_os_error():
+ """reap_worker_zombies() does not propagate generic OSError from os.waitpid."""
+ from unittest.mock import patch
+
+ with patch("hermes_cli.kanban_db.os.waitpid", side_effect=OSError("test error")):
+ result = kb.reap_worker_zombies()
+ assert result == []
+
+
+def test_zombie_reaper_runs_despite_board_connect_failure():
+ """reap_worker_zombies runs even when a board tick raises an error."""
+ from unittest.mock import patch
+ call_count = [0]
+ def fake_waitpid(pid, flags):
+ call_count[0] += 1
+ if call_count[0] <= 2:
+ return [12345, 67890][call_count[0] - 1], 0
+ return 0, 0
+ with patch("hermes_cli.kanban_db.os.waitpid", side_effect=fake_waitpid):
+ with patch("hermes_cli.kanban_db._record_worker_exit"):
+ # Simulate a board tick failure before reaping
+ try:
+ raise sqlite3.OperationalError("disk I/O error")
+ except sqlite3.OperationalError:
+ pass
+
+ # Reaper still runs independently
+ pids = kb.reap_worker_zombies()
+
+ assert pids == [12345, 67890]
+
+
+def test_zombie_reaper_survives_all_boards_failing():
+ """reap_worker_zombies runs each tick regardless of board tick failures."""
+ from unittest.mock import patch
+
+ total_reaped = 0
+
+ def make_fake_waitpid(zombie_pids):
+ call_count = [0]
+
+ def fake_waitpid(pid, flags):
+ if call_count[0] < len(zombie_pids):
+ p = zombie_pids[call_count[0]]
+ call_count[0] += 1
+ return p, 0
+ return 0, 0
+ return fake_waitpid
+ # 5 ticks, 2 zombies per tick = 10 total
+ for tick in range(5):
+ pids = [tick * 100 + 1, tick * 100 + 2]
+ with patch(
+ "hermes_cli.kanban_db.os.waitpid", side_effect=make_fake_waitpid(pids)
+ ):
+ with patch("hermes_cli.kanban_db._record_worker_exit"):
+ pids = kb.reap_worker_zombies()
+ total_reaped += len(pids)
+
+ assert total_reaped == 10
+
+
+def test_dispatch_once_still_reaps_via_extracted_fn(kanban_home):
+ """The reaper inside dispatch_once still works after refactor to reap_worker_zombies()."""
+ from unittest.mock import patch
+
+ call_count = [0]
+
+ def fake_waitpid(pid, flags):
+ call_count[0] += 1
+ if call_count[0] == 1:
+ return 99999, 0
+ return 0, 0
+
+ with patch("hermes_cli.kanban_db.os.waitpid", side_effect=fake_waitpid):
+ with patch("hermes_cli.kanban_db._record_worker_exit"):
+ with patch("hermes_cli.kanban_db.os.name", "posix"):
+ pids = kb.reap_worker_zombies()
+
+ assert pids == [99999]
@@ -1919,6 +5433,40 @@ def test_write_txn_check_reads_correct_header_fields(tmp_path):
# ---------------------------------------------------------------------------
+def test_connect_closing_closes_connection_on_exit(tmp_path):
+ """The new context manager MUST actually close the underlying FD."""
+ db_path = tmp_path / "kanban.db"
+ kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
+ with kb.connect_closing(db_path=db_path) as conn:
+ conn.execute("SELECT 1").fetchone()
+ # After exit, the connection MUST be closed — subsequent execute
+ # should raise ProgrammingError.
+ with pytest.raises(sqlite3.ProgrammingError):
+ conn.execute("SELECT 1")
+
+
+def test_connect_closing_closes_on_exception(tmp_path):
+ """Connection closed even when the body raises."""
+ db_path = tmp_path / "kanban.db"
+ kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
+ captured = []
+ with pytest.raises(RuntimeError, match="boom"):
+ with kb.connect_closing(db_path=db_path) as conn:
+ captured.append(conn)
+ raise RuntimeError("boom")
+ with pytest.raises(sqlite3.ProgrammingError):
+ captured[0].execute("SELECT 1")
+
+
+def test_connect_closing_yields_usable_connection(tmp_path):
+ """Smoke test: schema is initialized and basic ops work."""
+ db_path = tmp_path / "kanban.db"
+ kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
+ with kb.connect_closing(db_path=db_path) as conn:
+ tid = kb.create_task(conn, title="closing-cm test")
+ task = kb.get_task(conn, tid)
+ assert task is not None
+ assert task.title == "closing-cm test"
def test_bare_connect_does_not_close_on_context_exit(tmp_path):
@@ -1935,3 +5483,186 @@ def test_bare_connect_does_not_close_on_context_exit(tmp_path):
# Still usable after with-block exit (the leak).
conn.execute("SELECT 1").fetchone()
conn.close() # explicit close to avoid leaking THIS test
+
+
+# ---------------------------------------------------------------------------
+# Worker-log failure classification (truthful spawn telemetry, t_543dce5d)
+# ---------------------------------------------------------------------------
+
+def _setup_dead_worker(conn, monkeypatch, *, pid, log_text=None, title="t"):
+ """Create a running task with a dead pid and an optional worker log."""
+ import hermes_cli.kanban_db as _kb
+
+ monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: False)
+ tid = kb.create_task(conn, title=title, assignee="a")
+ host = _kb._claimer_id().split(":", 1)[0]
+ conn.execute(
+ "UPDATE tasks SET status='running', worker_pid=?, claim_lock=? "
+ "WHERE id=?",
+ (pid, f"{host}:w1", tid),
+ )
+ conn.commit()
+ if log_text is not None:
+ log_dir = kb.worker_logs_dir()
+ log_dir.mkdir(parents=True, exist_ok=True)
+ (log_dir / f"{tid}.log").write_text(log_text, encoding="utf-8")
+ return tid
+
+
+def test_billing_wall_clean_exit_not_scored_as_protocol_violation(
+ kanban_home, monkeypatch,
+):
+ """A worker that dies on HTTP 402 with rc=0 must be requeued as
+ rate_limited — NOT recorded as a protocol violation (incident
+ t_543dce5d: 12 consecutive 402s were mis-scored as violations and
+ burned the near-exhausted grant on immediate respawns)."""
+ import hermes_cli.kanban_db as _kb
+
+ with kb.connect() as conn:
+ tid = _setup_dead_worker(
+ conn, monkeypatch, pid=91001,
+ log_text=(
+ "starting worker...\n"
+ 'HTTP 402 "Insufficient available credits for this '
+ 'inference request"\n'
+ ),
+ )
+ # Simulate the reap registry seeing a clean rc=0 exit.
+ monkeypatch.setattr(
+ _kb, "_classify_worker_exit", lambda _pid: ("clean_exit", 0)
+ )
+ crashed = kb.detect_crashed_workers(conn)
+ assert crashed == [] # not a crash
+ rate_limited = getattr(
+ kb.detect_crashed_workers, "_last_rate_limited", []
+ )
+ assert tid in rate_limited
+
+ task = kb.get_task(conn, tid)
+ assert task.status == "ready"
+ assert "billing wall" in (task.last_failure_error or "")
+ # No failure counted — the breaker must not see this.
+ row = conn.execute(
+ "SELECT consecutive_failures FROM tasks WHERE id=?", (tid,)
+ ).fetchone()
+ assert row["consecutive_failures"] == 0
+ # Run recorded as rate_limited, not crashed.
+ run = conn.execute(
+ "SELECT outcome FROM task_runs WHERE task_id=? "
+ "ORDER BY id DESC LIMIT 1", (tid,),
+ ).fetchone()
+ if run is not None:
+ assert run["outcome"] in (None, "rate_limited")
+ # Event stream carries the loud error_code.
+ ev = conn.execute(
+ "SELECT kind, payload FROM task_events WHERE task_id=? "
+ "AND kind='rate_limited' ORDER BY id DESC LIMIT 1", (tid,),
+ ).fetchone()
+ assert ev is not None
+ import json as _json
+ assert _json.loads(ev["payload"])["error_code"] == "billing_exhausted"
+
+
+def test_unknown_skill_startup_crash_classified_loudly(
+ kanban_home, monkeypatch,
+):
+ """A worker that dies at startup on a bad skill pin must surface the
+ Unknown skill(s) detail instead of an opaque 'pid exited with code 1'."""
+ import hermes_cli.kanban_db as _kb
+
+ with kb.connect() as conn:
+ tid = _setup_dead_worker(
+ conn, monkeypatch, pid=91002,
+ log_text="Error: Unknown skill(s): hermes-multi-profile-orchestration\n",
+ )
+ monkeypatch.setattr(
+ _kb, "_classify_worker_exit", lambda _pid: ("nonzero_exit", 1)
+ )
+ crashed = kb.detect_crashed_workers(conn)
+ assert crashed == [tid]
+ task = kb.get_task(conn, tid)
+ err = task.last_failure_error or ""
+ assert "Unknown skill(s)" in err
+ assert "install the skill" in err
+ ev = conn.execute(
+ "SELECT payload FROM task_events WHERE task_id=? "
+ "AND kind='crashed' ORDER BY id DESC LIMIT 1", (tid,),
+ ).fetchone()
+ import json as _json
+ payload = _json.loads(ev["payload"])
+ assert payload["error_code"] == "unknown_skill"
+ assert "Unknown skill(s)" in payload["error_detail"]
+
+
+def test_missing_profile_startup_crash_classified_loudly(
+ kanban_home, monkeypatch,
+):
+ """A worker that dies because 'hermes -p X' can't find the profile
+ must surface the actionable detail."""
+ import hermes_cli.kanban_db as _kb
+
+ with kb.connect() as conn:
+ tid = _setup_dead_worker(
+ conn, monkeypatch, pid=91003,
+ log_text="Profile 'ghost-profile' does not exist. Create it with: hermes profile create ghost-profile\n",
+ )
+ monkeypatch.setattr(
+ _kb, "_classify_worker_exit", lambda _pid: ("nonzero_exit", 1)
+ )
+ crashed = kb.detect_crashed_workers(conn)
+ assert crashed == [tid]
+ task = kb.get_task(conn, tid)
+ err = task.last_failure_error or ""
+ assert "does not exist" in err
+ assert "reassign" in err
+ ev = conn.execute(
+ "SELECT payload FROM task_events WHERE task_id=? "
+ "AND kind='crashed' ORDER BY id DESC LIMIT 1", (tid,),
+ ).fetchone()
+ import json as _json
+ assert _json.loads(ev["payload"])["error_code"] == "missing_profile"
+
+
+def test_no_log_falls_back_to_exit_classification(kanban_home, monkeypatch):
+ """Without a worker log, behavior is unchanged (opaque but honest)."""
+ import hermes_cli.kanban_db as _kb
+
+ with kb.connect() as conn:
+ tid = _setup_dead_worker(conn, monkeypatch, pid=91004, log_text=None)
+ monkeypatch.setattr(
+ _kb, "_classify_worker_exit", lambda _pid: ("nonzero_exit", 1)
+ )
+ crashed = kb.detect_crashed_workers(conn)
+ assert crashed == [tid]
+ task = kb.get_task(conn, tid)
+ assert (task.last_failure_error or "").startswith("pid 91004 exited")
+
+
+def test_stale_log_mtime_ignored(kanban_home, monkeypatch):
+ """A log older than the current run's started_at must not be used as
+ evidence — append-mode logs can carry a prior run's failure lines."""
+ import hermes_cli.kanban_db as _kb
+
+ with kb.connect() as conn:
+ tid = _setup_dead_worker(
+ conn, monkeypatch, pid=91005,
+ log_text="HTTP 402 Insufficient available credits\n",
+ )
+ # Backdate the log mtime far before started_at.
+ log_path = kb.worker_logs_dir() / f"{tid}.log"
+ old = time.time() - 86400
+ os.utime(log_path, (old, old))
+ # Give the task a started_at newer than the log, older than grace.
+ conn.execute(
+ "UPDATE tasks SET started_at=? WHERE id=?",
+ (int(time.time()) - 3600, tid),
+ )
+ conn.commit()
+ monkeypatch.setattr(
+ _kb, "_classify_worker_exit", lambda _pid: ("clean_exit", 0)
+ )
+ crashed = kb.detect_crashed_workers(conn)
+ # Stale log ignored → falls through to protocol-violation path.
+ assert crashed == [tid]
+ task = kb.get_task(conn, tid)
+ assert "protocol violation" in (task.last_failure_error or "")
diff --git a/tests/hermes_cli/test_kanban_respawn_guard.py b/tests/hermes_cli/test_kanban_respawn_guard.py
new file mode 100644
index 000000000000..95bce393389a
--- /dev/null
+++ b/tests/hermes_cli/test_kanban_respawn_guard.py
@@ -0,0 +1,129 @@
+"""Focused regression tests for Kanban respawn guard behavior."""
+
+from __future__ import annotations
+
+import time
+from pathlib import Path
+
+import pytest
+
+from hermes_cli import kanban_db as kb
+
+
+@pytest.fixture
+def kanban_home(tmp_path, monkeypatch):
+ """Isolated HERMES_HOME with an empty kanban DB."""
+ home = tmp_path / ".hermes"
+ home.mkdir()
+ monkeypatch.setenv("HERMES_HOME", str(home))
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ kb.init_db()
+ return home
+
+
+def _make_code_task(conn, task_id: str) -> None:
+ """Mark the task as a code/PR-producing task (branch_name set).
+
+ ``check_respawn_guard`` deliberately scopes the ``active_pr`` guard to
+ code tasks (worktree workspace or a branch name) so evidence/research
+ tasks that merely cite PR URLs are not suppressed. These tests exercise
+ the guard itself, so the fixture task must look like a code task.
+ """
+ conn.execute(
+ "UPDATE tasks SET branch_name = 'fix/test-branch' WHERE id = ?",
+ (task_id,),
+ )
+
+
+def _add_pr_comment(conn, task_id: str, created_at: int) -> None:
+ conn.execute(
+ "INSERT INTO task_comments (task_id, author, body, created_at) "
+ "VALUES (?, 'worker', ?, ?)",
+ (
+ task_id,
+ "Opened https://github.com/totemx-AI/subsidysmart/pull/42",
+ created_at,
+ ),
+ )
+
+
+def test_respawn_guard_recent_pr_without_requeue_is_active(kanban_home):
+ """Recent PR evidence with no later requeue keeps the active_pr guard."""
+ with kb.connect() as conn:
+ task_id = kb.create_task(conn, title="has-pr", assignee="alice")
+ _make_code_task(conn, task_id)
+ _add_pr_comment(conn, task_id, int(time.time()) - 10)
+
+ reason = kb.check_respawn_guard(conn, task_id)
+
+ assert reason == "active_pr"
+
+
+@pytest.mark.parametrize("event_kind", ["promoted", "unblocked", "status", "reclaimed"])
+def test_respawn_guard_active_pr_bypassed_by_later_requeue(
+ kanban_home, event_kind
+):
+ """Every explicit requeue event after PR evidence permits more work."""
+ with kb.connect() as conn:
+ task_id = kb.create_task(
+ conn,
+ title=f"requeued-after-pr-{event_kind}",
+ assignee="alice",
+ )
+ _make_code_task(conn, task_id)
+ now = int(time.time())
+ _add_pr_comment(conn, task_id, now - 20)
+ conn.execute(
+ "INSERT INTO task_events (task_id, kind, created_at) VALUES (?, ?, ?)",
+ (task_id, event_kind, now - 10),
+ )
+
+ reason = kb.check_respawn_guard(conn, task_id)
+
+ assert reason is None
+
+
+def test_respawn_guard_active_pr_not_bypassed_by_earlier_requeue(kanban_home):
+ """A requeue before the PR comment does not supersede newer PR evidence."""
+ with kb.connect() as conn:
+ task_id = kb.create_task(
+ conn,
+ title="requeued-before-pr",
+ assignee="alice",
+ )
+ _make_code_task(conn, task_id)
+ now = int(time.time())
+ conn.execute(
+ "INSERT INTO task_events (task_id, kind, created_at) "
+ "VALUES (?, 'promoted', ?)",
+ (task_id, now - 20),
+ )
+ _add_pr_comment(conn, task_id, now - 10)
+
+ reason = kb.check_respawn_guard(conn, task_id)
+
+ assert reason == "active_pr"
+
+
+def test_respawn_guard_active_pr_not_bypassed_by_same_timestamp_requeue(
+ kanban_home,
+):
+ """A requeue at the PR comment timestamp is not strictly later evidence."""
+ with kb.connect() as conn:
+ task_id = kb.create_task(
+ conn,
+ title="requeued-at-pr-timestamp",
+ assignee="alice",
+ )
+ _make_code_task(conn, task_id)
+ created_at = int(time.time()) - 10
+ _add_pr_comment(conn, task_id, created_at)
+ conn.execute(
+ "INSERT INTO task_events (task_id, kind, created_at) "
+ "VALUES (?, 'promoted', ?)",
+ (task_id, created_at),
+ )
+
+ reason = kb.check_respawn_guard(conn, task_id)
+
+ assert reason == "active_pr"
diff --git a/tests/hermes_cli/test_repro_active_pr_guard.py b/tests/hermes_cli/test_repro_active_pr_guard.py
new file mode 100644
index 000000000000..8874be884f6a
--- /dev/null
+++ b/tests/hermes_cli/test_repro_active_pr_guard.py
@@ -0,0 +1,97 @@
+"""Reproduces: ready+spawnable task is skipped by check_respawn_guard's
+active_pr branch even though (a) global headroom exists and (b) the task
+is not a code/PR-producing task (workspace_kind='dir', no branch_name).
+
+Root cause: hermes_cli/kanban_db.py check_respawn_guard() step 4 (the
+active_pr guard) applies to EVERY ready task unconditionally on the
+pre-fix main. It has no restriction to code tasks (workspace_kind == 'worktree'
+or branch_name set). Any ready task with a GitHub PR URL anywhere in a
+comment within the guard window is deferred, even for review/triage/
+dir-workspace tasks that legitimately cite PR URLs in status comments
+(a common pattern -- see e.g. t_543dce5d's own review-handoff comments).
+
+This test is folded into PR #13 (branch
+fix/t-543dce5d-dispatcher-consolidated), which scopes the guard to
+code_task only. It is the regression test proving the dispatcher-level
+symptom (task not spawned despite headroom) is actually fixed, on top of
+the unit-level fixtures already in test_kanban_db.py
+(test_respawn_guard_ignores_pr_evidence_on_dir_task_without_branch, etc).
+
+NOTE (merge-lane, PR #13 vs main): the "dir" workspace target must exist
+on disk before dispatch_once claims the task. fork/main's
+kanban_preflight capability-gate framework (adopted by the conflict
+resolution merging this branch onto main) runs validate_pre_dispatch()
+-- which rejects a "dir" workspace whose configured path is not an
+existing directory (code "workspace_unavailable") -- BEFORE
+check_respawn_guard() ever runs. Pre-merge, this branch had no such
+precondition, so a nonexistent tmp_path subdirectory was fine. Create
+the directory so the test still reaches the guard logic under test
+rather than tripping the (unrelated, correctly-behaving) new gate.
+"""
+from pathlib import Path
+import pytest
+
+from hermes_cli import kanban_db as kb
+
+
+@pytest.fixture
+def kanban_home(tmp_path, monkeypatch):
+ home = tmp_path / ".hermes"
+ home.mkdir()
+ monkeypatch.setenv("HERMES_HOME", str(home))
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ kb.init_db()
+ return home
+
+
+@pytest.fixture
+def all_assignees_spawnable(monkeypatch):
+ from hermes_cli import profiles
+ monkeypatch.setattr(profiles, "profile_exists", lambda name: True)
+
+
+def test_active_pr_guard_wrongly_skips_non_code_ready_task_despite_headroom(
+ kanban_home, all_assignees_spawnable, tmp_path,
+):
+ spawns = []
+
+ def fake_spawn(task, workspace):
+ spawns.append(task.id)
+
+ with kb.connect() as conn:
+ # A non-code task: dir workspace, no branch_name. This is the
+ # shape used by review/triage/decomposer handoff tasks that
+ # legitimately reference a GitHub PR URL in a status comment
+ # without themselves being the PR-producing branch.
+ # workspace_path must be an absolute path for workspace_kind="dir"
+ # (resolve_workspace raises otherwise) -- unrelated to the guard
+ # logic under test, just satisfying dispatch_once's spawn path.
+ # It must also exist on disk: kanban_preflight.validate_dispatch_candidate
+ # rejects a "dir" workspace whose path isn't a real directory
+ # (code "workspace_unavailable") before the guard under test ever runs.
+ dir_workspace = tmp_path / "review-handoff-workspace"
+ dir_workspace.mkdir()
+ tid = kb.create_task(
+ conn,
+ title="review handoff citing a PR URL",
+ assignee="alice",
+ workspace_kind="dir",
+ workspace_path=str(dir_workspace),
+ )
+ kb.add_comment(
+ conn, tid, "some-worker",
+ "Handoff: see https://github.com/SSC-ENG/hermes-agent/pull/13 "
+ "for the reviewed branch. Needs another pass.",
+ )
+ # Ample global headroom: nothing else running, cap far above 0.
+ result = kb.dispatch_once(
+ conn, spawn_fn=fake_spawn, max_in_progress=8,
+ )
+
+ # EXPECTED (correct) behavior: headroom exists, task is spawnable,
+ # task is NOT a code/PR-producing task -> it should be spawned.
+ assert tid in spawns, (
+ f"task {tid} was NOT spawned despite global headroom; "
+ f"respawn_guarded={result.respawn_guarded!r}"
+ )
+ assert (tid, "active_pr") not in result.respawn_guarded
diff --git a/tests/hermes_cli/test_update_lock.py b/tests/hermes_cli/test_update_lock.py
index 93dfef710165..290d94c7c043 100644
--- a/tests/hermes_cli/test_update_lock.py
+++ b/tests/hermes_cli/test_update_lock.py
@@ -22,6 +22,7 @@
import pytest
from hermes_cli.update_lock import (
+ HANDOFF_PID_ENV,
UPDATE_MARKER_MAX_AGE_SECONDS,
UpdateLock,
describe_holder,
@@ -175,3 +176,51 @@ def test_unwritable_marker_location_does_not_block_the_update(tmp_path):
assert lock.acquire() is True
assert lock.acquired is False, "nothing was written, so there is nothing to release"
+
+
+class TestHandoffFromOrchestratingUpdater:
+ """The Tauri updater holds the marker, then spawns ``hermes update``.
+
+ The regression: the child saw its own parent's live marker and exited 2,
+ so every GUI update failed with "Hermes is still running" and retrying
+ just re-ran the same self-deadlock. The parent names its pid in
+ HANDOFF_PID_ENV; a live holder matching it is our own orchestrator.
+ """
+
+ def test_child_runs_under_the_parents_live_claim(self, marker, monkeypatch):
+ # Stand in for the parent updater with our own (live) pid.
+ marker.write_text(f"{os.getpid()}\n{int(time.time())}\n", encoding="utf-8")
+ monkeypatch.setenv(HANDOFF_PID_ENV, str(os.getpid()))
+
+ lock = UpdateLock(path=marker)
+ assert lock.acquire() is True
+ assert lock.acquired is False, "the parent's claim is not ours to own"
+
+ lock.release()
+ assert marker.exists(), "the parent still needs its marker after our stage ends"
+ assert int(marker.read_text(encoding="utf-8").splitlines()[0]) == os.getpid()
+
+ def test_handoff_pid_that_is_not_the_live_holder_grants_nothing(self, marker, monkeypatch):
+ """The env var alone must not bypass the lock."""
+ marker.write_text(f"{os.getpid()}\n{int(time.time())}\n", encoding="utf-8")
+ monkeypatch.setenv(HANDOFF_PID_ENV, str(os.getpid() + 1))
+
+ lock = UpdateLock(path=marker)
+ assert lock.acquire() is False
+ assert lock.holder is not None
+
+ @pytest.mark.parametrize("value", ["", "not-a-pid", "-1", "0"], ids=["empty", "garbage", "negative", "zero"])
+ def test_malformed_handoff_values_fall_back_to_refusal(self, marker, monkeypatch, value):
+ marker.write_text(f"{os.getpid()}\n{int(time.time())}\n", encoding="utf-8")
+ monkeypatch.setenv(HANDOFF_PID_ENV, value)
+
+ assert UpdateLock(path=marker).acquire() is False
+
+ def test_handoff_env_with_no_marker_claims_normally(self, marker, monkeypatch):
+ """A handoff pid must not stop us writing our own claim when unlocked."""
+ monkeypatch.setenv(HANDOFF_PID_ENV, str(os.getpid()))
+
+ lock = UpdateLock(path=marker)
+ assert lock.acquire() is True
+ assert lock.acquired is True
+ assert int(marker.read_text(encoding="utf-8").splitlines()[0]) == os.getpid()
diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py
index 78df7b99c9da..f36b652d0aac 100644
--- a/tests/plugins/test_kanban_dashboard_plugin.py
+++ b/tests/plugins/test_kanban_dashboard_plugin.py
@@ -80,6 +80,93 @@ def test_board_empty(client):
assert data["latest_event_id"] == 0
+def test_dispatcher_health_endpoint_reports_missing_signal(client):
+ response = client.get("/api/plugins/kanban/dispatcher/health")
+
+ assert response.status_code == 200
+ assert response.json()["available"] is False
+ assert response.json()["stale"] is True
+ assert response.json()["signal"] is None
+
+
+def test_dispatcher_health_endpoint_exposes_persisted_actionable_signal(client):
+ now = int(time.time())
+ kb.write_dispatcher_health({
+ "schema_version": 1,
+ "updated_at": now,
+ "status": "actionable",
+ "actionable": True,
+ "consecutive_zero_spawn_ticks": 6,
+ "health_window": 6,
+ "dispatchable_count": 2,
+ "free_global_slots": 3,
+ "code": "dispatcher_zero_spawn_with_capacity",
+ })
+
+ response = client.get("/api/plugins/kanban/dispatcher/health")
+
+ assert response.status_code == 200
+ payload = response.json()
+ assert payload["available"] is True
+ assert payload["stale"] is False
+ assert payload["signal"]["actionable"] is True
+ assert payload["signal"]["code"] == "dispatcher_zero_spawn_with_capacity"
+
+
+def test_dispatcher_health_endpoint_marks_old_signal_stale(client):
+ kb.write_dispatcher_health({
+ "schema_version": 1,
+ "updated_at": int(time.time()) - 601,
+ "status": "ok",
+ })
+
+ payload = client.get("/api/plugins/kanban/dispatcher/health").json()
+
+ assert payload["available"] is True
+ assert payload["stale"] is True
+ assert payload["age_seconds"] >= 601
+
+
+def test_dispatcher_health_endpoint_marks_degraded_signal_unavailable(client):
+ kb.write_dispatcher_health({
+ "schema_version": 1,
+ "updated_at": int(time.time()),
+ "status": "unavailable",
+ "degraded": True,
+ "probe_ok": False,
+ "probe_errors": [{"slug": "broken", "error": "DatabaseError"}],
+ })
+
+ payload = client.get("/api/plugins/kanban/dispatcher/health").json()
+
+ assert payload["available"] is False
+ assert payload["stale"] is False
+ assert payload["signal"]["probe_ok"] is False
+
+
+@pytest.mark.parametrize("updated_at", ["not-a-timestamp", None])
+def test_dispatcher_health_endpoint_marks_malformed_timestamp_stale(client, updated_at):
+ kb.write_dispatcher_health({"schema_version": 1, "updated_at": updated_at})
+
+ payload = client.get("/api/plugins/kanban/dispatcher/health").json()
+
+ assert payload["available"] is True
+ assert payload["stale"] is True
+ assert payload["age_seconds"] is None
+
+
+def test_dispatcher_health_endpoint_marks_unreadable_json_unavailable(client):
+ path = kb.dispatcher_health_path()
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("{not json", encoding="utf-8")
+
+ payload = client.get("/api/plugins/kanban/dispatcher/health").json()
+
+ assert payload["available"] is False
+ assert payload["stale"] is True
+ assert payload["signal"] is None
+
+
# ---------------------------------------------------------------------------
# POST /tasks then GET /board sees it
# ---------------------------------------------------------------------------
diff --git a/tests/tools/test_windows_native_support.py b/tests/tools/test_windows_native_support.py
index d5e1f9357e6d..81be4319b557 100644
--- a/tests/tools/test_windows_native_support.py
+++ b/tests/tools/test_windows_native_support.py
@@ -52,10 +52,10 @@ def _reset_configured(self, monkeypatch):
yield
sys.modules.pop("hermes_cli.stdio", None)
- def test_no_op_on_posix(self):
+ def test_no_op_on_posix(self, monkeypatch):
from hermes_cli import stdio
- assert stdio.is_windows() is False
+ monkeypatch.setattr(stdio, "is_windows", lambda: False)
result = stdio.configure_windows_stdio()
assert result is False
diff --git a/ui-tui/packages/hermes-ink/index.d.ts b/ui-tui/packages/hermes-ink/index.d.ts
index a0db6e7e0c79..77469aa67bf7 100644
--- a/ui-tui/packages/hermes-ink/index.d.ts
+++ b/ui-tui/packages/hermes-ink/index.d.ts
@@ -29,6 +29,7 @@ export { default as useStdin } from './src/ink/hooks/use-stdin.ts'
export { useTabStatus } from './src/ink/hooks/use-tab-status.ts'
export { useTerminalFocus } from './src/ink/hooks/use-terminal-focus.ts'
export { useTerminalTitle } from './src/ink/hooks/use-terminal-title.ts'
+export type { TerminalTitlePair } from './src/ink/hooks/use-terminal-title.ts'
export { useTerminalViewport } from './src/ink/hooks/use-terminal-viewport.ts'
export { default as measureElement } from './src/ink/measure-element.ts'
export { createRoot, forceRedraw, default as render, renderSync } from './src/ink/root.ts'
diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts
index 1488124034d2..6bca33a6f435 100644
--- a/ui-tui/packages/hermes-ink/src/entry-exports.ts
+++ b/ui-tui/packages/hermes-ink/src/entry-exports.ts
@@ -21,6 +21,7 @@ export { default as useStdin } from './ink/hooks/use-stdin.js'
export { useTabStatus } from './ink/hooks/use-tab-status.js'
export { useTerminalFocus } from './ink/hooks/use-terminal-focus.js'
export { useTerminalTitle } from './ink/hooks/use-terminal-title.js'
+export type { TerminalTitlePair } from './ink/hooks/use-terminal-title.js'
export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.js'
export { default as measureElement } from './ink/measure-element.js'
export { scrollFastPathStats, type ScrollFastPathStats } from './ink/render-node-to-output.js'
diff --git a/ui-tui/packages/hermes-ink/src/ink/hooks/use-terminal-title.ts b/ui-tui/packages/hermes-ink/src/ink/hooks/use-terminal-title.ts
index 6b5b28f5c3e9..b3ec7a0fa54d 100644
--- a/ui-tui/packages/hermes-ink/src/ink/hooks/use-terminal-title.ts
+++ b/ui-tui/packages/hermes-ink/src/ink/hooks/use-terminal-title.ts
@@ -7,15 +7,20 @@ import { TerminalWriteContext } from '../useTerminalNotification.js'
/**
* Declaratively set the terminal tab/window title.
*
- * Pass a string to set the title. ANSI escape sequences are stripped
- * automatically so callers don't need to know about terminal encoding.
+ * Pass a single string to set both the tab and window title (OSC 0).
+ * Pass `{ tab, window }` to set them independently: the short `tab` string
+ * goes to OSC 1 (icon/tab label) and the longer `window` string goes to
+ * OSC 2 (window title bar). This matters for terminals like Apple
+ * Terminal.app whose narrow background tabs truncate the title from the
+ * left — a single long OSC 0 string leaves only the tail visible, while a
+ * separate short OSC 1 keeps the session name readable.
+ *
* Pass `null` to opt out — the hook becomes a no-op and leaves the
* terminal title untouched.
*
* On Windows, uses `process.title` (classic conhost doesn't support OSC).
- * Elsewhere, writes OSC 0 (set title+icon) via Ink's stdout.
*/
-export function useTerminalTitle(title: string | null): void {
+export function useTerminalTitle(title: string | TerminalTitlePair | null): void {
const writeRaw = useContext(TerminalWriteContext)
useEffect(() => {
@@ -23,12 +28,37 @@ export function useTerminalTitle(title: string | null): void {
return
}
- const clean = stripAnsi(title)
-
if (process.platform === 'win32') {
+ const clean = stripAnsi(typeof title === 'string' ? title : (title.window ?? title.tab ?? ''))
process.title = clean
- } else {
- writeRaw(osc(OSC.SET_TITLE_AND_ICON, clean))
+
+ return
+ }
+
+ if (typeof title === 'string') {
+ writeRaw(osc(OSC.SET_TITLE_AND_ICON, stripAnsi(title)))
+
+ return
+ }
+
+ // Separate tab (OSC 1) and window (OSC 2) titles so narrow tab bars
+ // show the short session name instead of a truncated tail.
+ const tab = stripAnsi(title.tab ?? '')
+ const window = stripAnsi(title.window ?? '')
+
+ if (tab && window) {
+ writeRaw(osc(OSC.SET_ICON, tab) + osc(OSC.SET_TITLE, window))
+ } else if (window) {
+ writeRaw(osc(OSC.SET_TITLE_AND_ICON, window))
+ } else if (tab) {
+ writeRaw(osc(OSC.SET_TITLE_AND_ICON, tab))
}
}, [title, writeRaw])
}
+
+export interface TerminalTitlePair {
+ /** Short title for the tab/icon label (OSC 1). */
+ tab?: string
+ /** Full title for the window title bar (OSC 2). */
+ window?: string
+}
diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts
index cc3cea4a3b79..5766ebb6a7e4 100644
--- a/ui-tui/src/app/useMainApp.ts
+++ b/ui-tui/src/app/useMainApp.ts
@@ -626,7 +626,12 @@ export function useMainApp(gw: GatewayClient) {
const tabCwd = ui.info?.cwd
useTerminalTitle(
- model ? composeTabTitle(marker, ui.sessionTitle, model, tabCwd ? shortCwd(tabCwd, 24) : '') : 'Hermes'
+ model
+ ? {
+ tab: composeTabTitle(marker, ui.sessionTitle, '', ''),
+ window: composeTabTitle(marker, ui.sessionTitle, model, tabCwd ? shortCwd(tabCwd, 24) : '')
+ }
+ : 'Hermes'
)
useEffect(() => {
diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts
index 94df7504f508..7f7a53d9760c 100644
--- a/ui-tui/src/types/hermes-ink.d.ts
+++ b/ui-tui/src/types/hermes-ink.d.ts
@@ -167,7 +167,11 @@ declare module '@hermes/ink' {
readonly write: (data: string) => boolean
}
export function useTerminalFocus(): boolean
- export function useTerminalTitle(title: string | null): void
+ export function useTerminalTitle(title: string | TerminalTitlePair | null): void
+ export interface TerminalTitlePair {
+ tab?: string
+ window?: string
+ }
export function useDeclaredCursor(args: {
readonly line: number
readonly column: number
diff --git a/website/docs/developer-guide/adding-providers.md b/website/docs/developer-guide/adding-providers.md
index 0898d698ac8c..1964c194629d 100644
--- a/website/docs/developer-guide/adding-providers.md
+++ b/website/docs/developer-guide/adding-providers.md
@@ -338,11 +338,11 @@ For docs-only examples, the exact file set may differ. The point is to cover:
- provider:model parsing
- any adapter-specific message conversion
-Run tests with xdist disabled:
+Run the targeted tests (or use `scripts/run_tests.sh`, which runs each file in its own subprocess):
```bash
source venv/bin/activate
-python -m pytest tests/hermes_cli/test_runtime_provider_resolution.py tests/cli/test_cli_provider_resolution.py tests/hermes_cli/test_setup_model_provider.py tests/run_agent/test_provider_parity.py -n0 -q
+python -m pytest tests/hermes_cli/test_runtime_provider_resolution.py tests/cli/test_cli_provider_resolution.py tests/hermes_cli/test_setup_model_provider.py tests/run_agent/test_provider_parity.py -q
```
For deeper changes, run the full suite before pushing:
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-providers.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-providers.md
index 04245b32e1cb..638f47df2e70 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-providers.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-providers.md
@@ -338,11 +338,11 @@ Prompt(提示词)缓存和 provider 专属的调节项很容易出现回归
- `provider:model` 解析
- 任何适配器专属的消息转换
-使用禁用 xdist 的方式运行测试:
+运行目标测试(或使用 `scripts/run_tests.sh`,它在独立子进程中运行每个文件):
```bash
source venv/bin/activate
-python -m pytest tests/test_runtime_provider_resolution.py tests/test_cli_provider_resolution.py tests/test_cli_model_command.py tests/test_setup_model_selection.py -n0 -q
+python -m pytest tests/test_runtime_provider_resolution.py tests/test_cli_provider_resolution.py tests/test_cli_model_command.py tests/test_setup_model_selection.py -q
```
对于更深层的修改,在推送前运行完整测试套件:
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md
index 986eb015d486..c18bb063ce2a 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md
@@ -698,20 +698,20 @@ mintty / git-bash 行为相同(Alt+Enter 全屏),除非你在选项 →
### 测试/贡献
-**`scripts/run_tests.sh` 在 Windows 上无法直接使用** — 它查找 POSIX venv 布局(`.venv/bin/activate`)。Hermes 安装的 venv 位于 `venv/Scripts/`,也没有 pip 或 pytest(为减小安装体积而精简)。解决方案:将 `pytest + pytest-xdist + pyyaml` 安装到系统 Python 3.11 用户站点,然后设置 `PYTHONPATH` 直接调用 pytest:
+**`scripts/run_tests.sh` 在 Windows 上无法直接使用** — 它查找 POSIX venv 布局(`.venv/bin/activate`)。Hermes 安装的 venv 位于 `venv/Scripts/`,也没有 pip 或 pytest(为减小安装体积而精简)。解决方案:将 `pytest + pyyaml` 安装到系统 Python 3.11 用户站点,然后设置 `PYTHONPATH` 直接调用 pytest:
```bash
-"/c/Program Files/Python311/python" -m pip install --user pytest pytest-xdist pyyaml
+"/c/Program Files/Python311/python" -m pip install --user pytest pyyaml
export PYTHONPATH="$(pwd)"
-"/c/Program Files/Python311/python" -m pytest tests/foo/test_bar.py -v --tb=short -n 0
+"/c/Program Files/Python311/python" -m pytest tests/foo/test_bar.py -v --tb=short
```
-使用 `-n 0` 而非 `-n 4` — `pyproject.toml` 的默认 `addopts` 已包含 `-n`,且 wrapper 的 CI 一致性保证不适用于非 POSIX 环境。
+仓库已不再使用 pytest-xdist——规范 runner 通过 `run_tests_parallel.py` 做按文件子进程隔离,但该 wrapper 仅支持 POSIX,其 CI 一致性保证不适用于非 POSIX 环境。
**仅 POSIX 的测试需要跳过守卫。** 代码库中已有的常见标记:
- 符号链接——Windows 上需要提升权限
- `0o600` 文件模式——POSIX 模式位在 NTFS 上默认不强制执行
-- `signal.SIGALRM`——仅 Unix(参见 `tests/conftest.py::_enforce_test_timeout`)
+- `signal.SIGALRM`——仅 Unix(每测试超时不再直接使用它;参见 `tests/conftest.py::pytest_configure` 中的 win32 timeout-method shim)
- Winsock / Windows 特有回归——`@pytest.mark.skipif(sys.platform != "win32", ...)`
使用现有的跳过模式风格(`sys.platform == "win32"` 或 `sys.platform.startswith("win")`)以与测试套件其余部分保持一致。
@@ -891,19 +891,19 @@ python -m pytest tests/tools/ -q # 特定区域
- 推送任何变更前运行完整套件
- 使用 `-o 'addopts='` 清除任何内置的 pytest 标志
-**Windows 贡献者:** `scripts/run_tests.sh` 目前查找 POSIX venv(`.venv/bin/activate` / `venv/bin/activate`),在 Windows 上会报错,因为布局是 `venv/Scripts/activate` + `python.exe`。Hermes 安装的 venv 位于 `venv/Scripts/`,也没有 `pip` 或 `pytest`——为终端用户安装体积而精简。解决方案:将 pytest + pytest-xdist + pyyaml 安装到系统 Python 3.11 用户站点(`/c/Program Files/Python311/python -m pip install --user pytest pytest-xdist pyyaml`),然后直接运行测试:
+**Windows 贡献者:** `scripts/run_tests.sh` 目前查找 POSIX venv(`.venv/bin/activate` / `venv/bin/activate`),在 Windows 上会报错,因为布局是 `venv/Scripts/activate` + `python.exe`。Hermes 安装的 venv 位于 `venv/Scripts/`,也没有 `pip` 或 `pytest`——为终端用户安装体积而精简。解决方案:将 pytest + pyyaml 安装到系统 Python 3.11 用户站点(`/c/Program Files/Python311/python -m pip install --user pytest pyyaml`),然后直接运行测试:
```bash
export PYTHONPATH="$(pwd)"
-"/c/Program Files/Python311/python" -m pytest tests/tools/test_foo.py -v --tb=short -n 0
+"/c/Program Files/Python311/python" -m pytest tests/tools/test_foo.py -v --tb=short
```
-使用 `-n 0`(而非 `-n 4`),因为 `pyproject.toml` 的默认 `addopts` 已包含 `-n`,且 wrapper 的 CI 一致性保证不适用于非 POSIX 环境。
+仓库已不再使用 pytest-xdist——规范 runner 通过 `run_tests_parallel.py` 做按文件子进程隔离,但该 wrapper 仅支持 POSIX,其 CI 一致性保证不适用于非 POSIX 环境。
**跨平台测试守卫:** 使用仅 POSIX 系统调用的测试需要跳过标记。代码库中已有的常见标记:
- 符号链接创建 → `@pytest.mark.skipif(sys.platform == "win32", reason="Symlinks require elevated privileges on Windows")`(参见 `tests/cron/test_cron_script.py`)
- POSIX 文件模式(0o600 等)→ `@pytest.mark.skipif(sys.platform.startswith("win"), reason="POSIX mode bits not enforced on Windows")`(参见 `tests/hermes_cli/test_auth_toctou_file_modes.py`)
-- `signal.SIGALRM` → 仅 Unix(参见 `tests/conftest.py::_enforce_test_timeout`)
+- `signal.SIGALRM` → 仅 Unix(每测试超时不再直接使用它;参见 `tests/conftest.py::pytest_configure` 中的 win32 timeout-method shim)
- 实时 Winsock / Windows 特有回归测试 → `@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific regression")`
**仅 monkeypatch `sys.platform` 是不够的**,当被测代码还调用 `platform.system()` / `platform.release()` / `platform.mac_ver()` 时。这些函数独立重新读取真实 OS,因此在 Windows runner 上将 `sys.platform = "linux"` 的测试仍会看到 `platform.system() == "Windows"` 并走 Windows 分支。需要同时 patch 三者:
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/software-development/software-development-python-debugpy.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/software-development/software-development-python-debugpy.md
index a8276c5678fb..e3ea93f47b45 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/software-development/software-development-python-debugpy.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/software-development/software-development-python-debugpy.md
@@ -125,11 +125,9 @@ scripts/run_tests.sh tests/path/to/test_file.py::test_name --trace
scripts/run_tests.sh tests/path/to/test_file.py --showlocals --tb=long
```
-注意:`scripts/run_tests.sh` 默认使用 xdist(`-n 4`),pdb 在 xdist 下**无法正常工作**。请添加 `-p no:xdist` 或使用 `-n 0` 运行单个测试:
+注意:`scripts/run_tests.sh` 通过 `run_tests_parallel.py` 将每个测试文件放在捕获输出的子进程中运行(不使用 xdist),因此交互式 pdb 在 wrapper 下**无法正常工作**。请直接运行 pytest 使用 `--pdb`:
```bash
-scripts/run_tests.sh tests/foo_test.py::test_bar --pdb -p no:xdist
-# 或
source .venv/bin/activate
python -m pytest tests/foo_test.py::test_bar --pdb
```
@@ -294,7 +292,7 @@ nc 127.0.0.1 4444
## 调试 Hermes 特定进程
### 测试
-参见方案 3。始终添加 `-p no:xdist` 或在不使用 xdist 的情况下运行单个测试。
+参见方案 3。wrapper 会捕获子进程输出,交互式 pdb 请直接运行 pytest。
### `run_agent.py` / CLI — 一次性运行
最简单:在可疑行附近添加 `breakpoint()`,然后正常运行 `hermes`。控制权将在暂停点返回到你的终端。
@@ -326,7 +324,7 @@ set_trace(host="127.0.0.1", port=4444) # 在你想捕获的 RPC 处理器中
## 常见陷阱
-1. **pdb 在 pytest-xdist 下静默失效。** 你不会看到提示符,测试只会挂起。始终使用 `-p no:xdist` 或 `-n 0`。
+1. **pdb 在并行/捕获输出的 runner 下静默失效。** 你不会看到提示符,测试只会挂起(pytest-xdist 与 `scripts/run_tests.sh` 的按文件捕获子进程均如此)。交互式调试请直接对单个文件运行 pytest。
2. **`breakpoint()` 在 CI / 非 TTY 环境中会挂起进程。** 本地使用没问题;永远不要提交它。添加 pre-commit grep 作为安全网。
@@ -351,7 +349,7 @@ set_trace(host="127.0.0.1", port=4444) # 在你想捕获的 RPC 处理器中
- [ ] `pip install debugpy` 后确认:`python -c "import debugpy; print(debugpy.__version__)"`
- [ ] 对于远程调试,确认端口确实在监听:`ss -tlnp | grep 5678`
-- [ ] 第一个断点确实触发(如果没有,可能是 `PYTHONBREAKPOINT=0`、在 xdist 下运行,或执行在附加前已结束)
+- [ ] 第一个断点确实触发(如果没有,可能是 `PYTHONBREAKPOINT=0`、在并行/捕获输出的 runner 下运行,或执行在附加前已结束)
- [ ] `where` / `w` 显示预期的调用栈
- [ ] 调试后清理:已提交代码中无残留的 `breakpoint()` / `set_trace()` / `debugpy.listen`
```bash
@@ -372,10 +370,10 @@ breakpoint()
**"这个测试单独运行通过,但在测试套件中失败。"**
```bash
-scripts/run_tests.sh tests/the_test.py --pdb -p no:xdist
-# 但如果只有与其他测试一起运行才失败:
+scripts/run_tests.sh tests/the_test.py # 先确认它在隔离 runner 下失败
+# 交互式调试,或只有与其他测试一起运行才失败时:
source .venv/bin/activate
-python -m pytest tests/ -x --pdb -p no:xdist
+python -m pytest tests/ -x --pdb
# 现在它会在状态积累后的确切失败测试处触发 pdb。
```