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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .claude/commands/orchestrate.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,9 +354,12 @@ verify:
mutex: cpu.heavy
success:
requireWorkEvidence: true
requireCommit: true # コミット前の状態を合格にしない(#3430: ワーカーが途中でターンを閉じ、未コミットのまま exit 0 になった)
requireScopeClean: true
```

- **`requireCommit: true` を外さない**(#3430)。外すと未コミットの変更も作業証跡に数え、ワーカーがコミット前に
ターンを閉じたとき `wait --verify` が exit 0 を返す。契約の「1 つにコミットする」と裁定をそろえる。
- **契約は未コミットで配ってよい**。`work-evidence` / `scope` ゲートは変更集合から契約ファイル自身を
除外する(#1580)ので、契約を置いただけの worktree が「作業済み」に見えることはない。
- `scope.allow` は**Issueが触ると宣言した範囲**を書く。広すぎる allow は scope ゲートを無力化し、
Expand Down Expand Up @@ -604,6 +607,7 @@ verify:
gates: [lint, typecheck] # テスト全体は PR の CI で見る(下の「検証ゲート」)
success:
requireWorkEvidence: true
requireCommit: true # 上の雛形と同じ(#3430: コミット前の状態を合格にしない)
requireScopeClean: true
```

Expand Down
2 changes: 2 additions & 0 deletions changelog.d/3430.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<!-- ### Fixed -->
- **fix(agent-health): Claude がバックグラウンドの作業を残してターンを閉じたとき wait が完了と読まないようにする** (#3430): Claude の Stop の時点で transcript に完了通知の届いていないバックグラウンドの作業(Bash の background 実行・Monitor・バックグラウンドのサブエージェント)があれば `self_resume_pending` を付け、Claude の source が `stopReportsSelfResume: true` を宣言する。`wait` はその Stop を #2614 と同じく最大 30 分保留し、作業の通知で開いた次のターンの Stop で完了とする。あわせて `/orchestrate` の契約の雛形に `requireCommit: true` を足し、コミット前の状態を合格にしない
3 changes: 2 additions & 1 deletion docs/module-reference.md

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions src/lib/hooks/agent-event-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,12 @@ declare module './sources/types' {
* that declares `true` is promising to write exactly that detail, and
* `commandmate wait` holds a completion on it only when this says so.
* `false` means "this tool's stop carries nothing CommandMate reads as
* such", not "this tool never resumes itself" — Claude Code does, and its
* `Stop` payload's `background_tasks` is the unmeasured candidate.
* such", not "this tool never resumes itself".
*
* `true` for antigravity alone, whose `Stop` payload carries `fullyIdle`
* ("true if all background tasks are done", agy's own hook contract).
* `true` for antigravity, whose `Stop` payload carries `fullyIdle`
* ("true if all background tasks are done", agy's own hook contract), and
* for claude since Issue #3430, whose transcript shows the background tasks
* not yet notified (`./sources/claude/self-resume`).
* Pinned by value for every source in
* `tests/unit/hooks/sources/capabilities.test.ts`.
*/
Expand Down
207 changes: 207 additions & 0 deletions src/lib/hooks/sources/claude/self-resume.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/**
* Whether a Claude Code `Stop` leaves background work behind that will wake the
* agent again (Issue #3430) — the Claude half of #2614.
*
* Claude Code ends a turn with work still running in two measured ways: a
* `Bash` call that outlived its timeout (or was started with
* `run_in_background`) and a `Monitor`. When that work finishes Claude queues a
* `<task-notification>` and, if the session is idle, opens a new turn on it by
* itself. Measured on 2026-10-08 (worktree commandmate-issue-3423): a worker
* ended its turn at 23:37:53 "waiting for the related-tests run to finish",
* `wait --verify` read that `Stop` as the end and verified an uncommitted
* worktree, and the notification opened the turn that committed at 23:38:32.
*
* ## What is read, and why not the `Stop` payload
*
* The payload carries `background_tasks`, but only its empty form has ever been
* captured (`tests/fixtures/hooks/claude/stop.json`), so its shape is a guess.
* The transcript names the same work in structured fields, measured on the
* transcripts under `~/.claude/projects` written in the four days to 2026-10-08:
*
* - a background `Bash`: `toolUseResult.backgroundTaskId` (1,008 results);
* - a `Monitor`: `toolUseResult.taskId` beside a boolean `persistent`
* (115 results);
* - a background subagent: `toolUseResult.isAsync: true` and `agentId`
* (30 results);
* - a `TaskStop`: `toolUseResult.task_id` (39 results). A stopped task is never
* notified, so this is what retires it.
*
* and each finished task is retired by the notification Claude delivers for it:
* a `type: "user"` prompt or a `queued_command` attachment whose text begins
* with `<task-notification>` and carries `<task-id>…</task-id>` — the id above.
* A notice that is only enqueued is not yet delivered, and the session will open
* a turn on it, so its task still counts as pending.
*
* ## Erring towards "finished"
*
* Every doubt answers "nothing pending", which is what every Claude `Stop` meant
* before this Issue: no transcript path, a path outside `~/.claude/projects`, a
* file that cannot be read, a line that does not parse. A persistent `Monitor`
* (a watch for the whole session, never notified on its own) is not counted
* either. A task that really is never notified — a process that died with its
* session — is bounded by `commandmate wait`'s `SELF_RESUME_HOLD_MS`.
*
* @module lib/hooks/sources/claude/self-resume
*/

import { closeSync, fstatSync, openSync, readSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
import { acceptPathUnderRoot } from '../transcript-history';
import { CLAUDE_TASK_NOTIFICATION_PREFIX } from './queued-notice';
import { CLAUDE_PROJECTS_DIR_SEGMENTS } from './transcript';

/** The `Stop` payload field naming the session's transcript. */
export const CLAUDE_TRANSCRIPT_PATH_FIELD = 'transcript_path';

/**
* How much of the transcript's tail is read on a `Stop`.
*
* A task is started before it is notified, so a window that holds the start
* holds the notification too; a start older than the window is simply not seen
* (the "finished" direction). 4 MiB is `CLAUDE_TRANSCRIPT_TAIL_BYTES`, the first
* read of `./history`, which holds 99% of turns whole (#2470).
*/
export const CLAUDE_SELF_RESUME_TAIL_BYTES = 4 * 1024 * 1024;

const TASK_ID_PATTERN = /<task-id>([^<]+)<\/task-id>/;

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function nonEmptyString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null;
}

/** The background task a tool result started, or null. */
function startedTaskId(result: Record<string, unknown>): string | null {
const bash = nonEmptyString(result.backgroundTaskId);
if (bash) return bash;
// Monitor. `persistent: true` watches for the life of the session and is not
// work the turn is waiting on.
const monitor = nonEmptyString(result.taskId);
if (monitor && result.persistent === false) return monitor;
const agent = nonEmptyString(result.agentId);
if (agent && result.isAsync === true) return agent;
return null;
}

/** The task a `<task-notification>` text retires, or null. */
function notifiedTaskId(text: unknown): string | null {
if (typeof text !== 'string') return null;
if (!text.trimStart().startsWith(CLAUDE_TASK_NOTIFICATION_PREFIX)) return null;
return TASK_ID_PATTERN.exec(text)?.[1] ?? null;
}

/** Every text a delivered `type: "user"` record carries. */
function userTexts(record: Record<string, unknown>): unknown[] {
const message = record.message;
if (!isRecord(message)) return [];
const content = message.content;
if (typeof content === 'string') return [content];
if (!Array.isArray(content)) return [];
return content.filter(isRecord).filter((b) => b.type === 'text').map((b) => b.text);
}

/**
* The ids of background tasks started in `text` (transcript JSONL) and not yet
* delivered a notification or stopped, in the order they were started.
*
* Pure. Lines that do not parse are skipped, as is everything a subagent wrote
* into the parent's file (`isSidechain: true`).
*/
export function pendingClaudeBackgroundTasks(text: string): string[] {
const started: string[] = [];
const retired = new Set<string>();
for (const line of text.split('\n')) {
if (line.trim() === '') continue;
let record: unknown;
try {
record = JSON.parse(line);
} catch {
continue;
}
if (!isRecord(record) || record.isSidechain === true) continue;

if (record.type === 'user') {
const result = record.toolUseResult;
if (isRecord(result)) {
const id = startedTaskId(result);
if (id) started.push(id);
const stopped = nonEmptyString(result.task_id);
if (stopped) retired.add(stopped);
}
for (const t of userTexts(record)) {
const id = notifiedTaskId(t);
if (id) retired.add(id);
}
} else if (record.type === 'attachment' && isRecord(record.attachment)) {
if (record.attachment.type !== 'queued_command') continue;
const id = notifiedTaskId(record.attachment.prompt);
if (id) retired.add(id);
}
}
return [...new Set(started)].filter((id) => !retired.has(id));
}

/**
* The last `maxBytes` of a file, starting at a line boundary; null when it
* cannot be read.
*/
function readTail(path: string, maxBytes: number): string | null {
let fd: number | null = null;
try {
fd = openSync(path, 'r');
const size = fstatSync(fd).size;
const start = Math.max(0, size - maxBytes);
const buffer = Buffer.alloc(size - start);
let read = 0;
while (read < buffer.length) {
const n = readSync(fd, buffer, read, buffer.length - read, start + read);
if (n === 0) break;
read += n;
}
const text = buffer.subarray(0, read).toString('utf8');
// A window opened mid-file starts mid-line; that line is dropped.
return start === 0 ? text : text.slice(text.indexOf('\n') + 1);
} catch {
return null;
} finally {
if (fd !== null) {
try {
closeSync(fd);
} catch {
// Nothing to do: the answer is already decided.
}
}
}
}

/** What {@link claudeStopLeavesBackgroundWork} reads besides the payload. */
export interface ClaudeSelfResumeOptions {
/** The home directory whose `.claude/projects` a transcript must be under. */
homeDir?: string;
tailBytes?: number;
}

/**
* Whether this `Stop` payload's session still has background work that has not
* been notified — i.e. whether Claude will open another turn by itself.
*
* Synchronous: it runs inside event normalization. `false` for every case it
* cannot vouch for (see the module comment).
*/
export function claudeStopLeavesBackgroundWork(
payload: Record<string, unknown>,
options: ClaudeSelfResumeOptions = {}
): boolean {
const hint = nonEmptyString(payload[CLAUDE_TRANSCRIPT_PATH_FIELD]);
if (!hint) return false;
const root = join(options.homeDir ?? homedir(), ...CLAUDE_PROJECTS_DIR_SEGMENTS);
const path = acceptPathUnderRoot(root, '.jsonl', hint);
if (!path) return false;
const text = readTail(path, options.tailBytes ?? CLAUDE_SELF_RESUME_TAIL_BYTES);
if (text === null) return false;
return pendingClaudeBackgroundTasks(text).length > 0;
}
40 changes: 32 additions & 8 deletions src/lib/hooks/sources/claude/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ import {
isUatIsolationEnabled,
UatIsolationLaunchRefusedError,
} from '@/config/uat-isolation';
import { AGENT_EVENT_TYPES } from '@/lib/hooks/agent-event-types';
import {
AGENT_EVENT_TYPES,
SELF_RESUME_PENDING_DETAIL,
type AgentEventType,
} from '@/lib/hooks/agent-event-types';
import { parseAskUserQuestionPayload } from '@/lib/hooks/ask-user-question-payload';
import {
buildClaudeLaunchCommand,
Expand All @@ -62,6 +66,7 @@ import {
import type { AgentEventSource, AgentLaunchContext, AgentLaunchPlan, Verdict } from '../types';
import { claudeModelSwitchMapper, extractClaudeSwitchedModel } from './model-switch';
import { isClaudeQueuedNoticePrompt } from './queued-notice';
import { claudeStopLeavesBackgroundWork } from './self-resume';
import { CLAUDE_CLI_TOOL_ID } from './tool-id';

/**
Expand Down Expand Up @@ -105,6 +110,24 @@ export function encodeClaudeVerdict(verdict: Verdict): Record<string, unknown> {
};
}

/**
* The subtype of one Claude event.
*
* Every event but `stop` is the shared snake_case reading (S2). A `stop` is
* {@link SELF_RESUME_PENDING_DETAIL} when the session's transcript shows
* background work that has not been notified yet (Issue #3430), and has no
* subtype otherwise — which is what every Claude `stop` had before.
*/
export function extractClaudeEventDetail(
event: AgentEventType,
payload: Record<string, unknown>
): string | null {
if (event === 'stop' && claudeStopLeavesBackgroundWork(payload)) {
return SELF_RESUME_PENDING_DETAIL;
}
return extractSnakeCaseEventDetail(event, payload);
}

/**
* Claude Code as an event source.
*
Expand Down Expand Up @@ -149,11 +172,12 @@ export const claudeAgentEventSource: AgentEventSource = definePushHookSource({
// JSONL when the poller asks (#2121); the gate dispatches on this word
// rather than on the tool id.
transcriptHistory: 'pull',
// Issue #2614. Claude Code does resume itself (background tasks, session
// crons), and its `Stop` payload carries `background_tasks` /
// `session_crons` — but only the empty form has been captured, so nothing
// reads them yet and this stays false until a non-empty one is measured.
stopReportsSelfResume: false,
// Issue #3430 (#2614 for Claude). A `Stop` whose transcript shows a
// background `Bash` / `Monitor` / subagent not yet notified carries
// `SELF_RESUME_PENDING_DETAIL`: the notification opens the next turn by
// itself. The payload's `background_tasks` is still unread — only its empty
// form has been captured. See `./self-resume` and `extractDetail` below.
stopReportsSelfResume: true,
},

// S1. A plain name table is enough *for this tool* — one native name, one
Expand Down Expand Up @@ -190,8 +214,8 @@ export const claudeAgentEventSource: AgentEventSource = definePushHookSource({
// `UserPromptSubmit` too, and continues that turn rather than opening one.
promptJoinsOpenTurn: isClaudeQueuedNoticePrompt,

// S2.
extractDetail: extractSnakeCaseEventDetail,
// S2, plus the `Stop` that leaves background work behind (Issue #3430).
extractDetail: extractClaudeEventDetail,

// S7. Both parsers are strict and answer null for anything they cannot vouch
// for; the callers turn null into "no structured data", which degrades to the
Expand Down
21 changes: 21 additions & 0 deletions tests/fixtures/claude-self-resume-3430/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Claude Code transcript — バックグラウンド作業を残したままの Stop(Issue #3430)

`transcript.jsonl` は 2026-10-08 の実 transcript(Claude Code 2.1.293、worktree `commandmate-issue-3423`、
session `0a8e0d46-…`)から抜き出したもの。**手で書いた想定ではなく、届いた記録の形をそのまま残している。**

## 何が起きたか

1. 23:37:00 `Bash` が 120 秒で打ち切られてバックグラウンドへ(`toolUseResult.backgroundTaskId: "beszxbdym"`)
2. 23:37:06〜23:37:49 `Monitor` を 6 本起動(`toolUseResult.taskId` と `persistent: false`)。うち `b5svpm92a` はターン中に通知が届いた(`queue-operation` の `remove` と `queued_command` の attachment)
3. 23:37:53 **最初の Stop**(47 行目の `stop_hook_summary`)。未通知の作業が 6 本残っている
4. 23:38:14 `beszxbdym` の `<task-notification>` が `type: "user"` のプロンプトとして届き、新しいターンが開く → コミット
5. 以後、Monitor の通知のたびにターンが開いて閉じ、最後の Stop(84 行目)で未通知の作業が 0 本になる

各 `stop_hook_summary` の直前までを読むと、未通知の作業は 6 → 5 → 4 → 3 → 2 → 1 → 0 本。

## 加工

- 載せたのは `user` / `assistant` / `queue-operation` / `queued_command` の attachment / `stop_hook_summary` だけ。他の type(`prompt_snapshot` など)は落とした
- 最初のプロンプト(契約本文)、バックグラウンドでないツール結果の本文、ツール入力のコマンドは伏せた(`(elided)` など)
- パスは `<TASK_DIR>` / `<CWD>` / `<HOME>` に、session id は `00000000-0000-4000-8000-000000003430` に置換
- 残した構造化フィールド: `toolUseResult` の `backgroundTaskId` / `taskId` / `persistent` / `timeoutMs` / `timedOutAfterMs` / `interrupted`
Loading
Loading