docs: add v1→v2 action-items analysis + SDK signal probe tool
- docs/v1-vs-v2/: full v1→v2 regression analysis (SUMMARY + 21 per-module docs + ACTION-ITEMS rollup with decisions + timezone recreation spec). - container/agent-runner/scripts/sdk-signal-probe.ts: empirical harness used to characterise Claude Agent SDK event/hook/stderr timing for the stuck-detection design in item 9. - src/channels/chat-sdk-bridge.ts: document the conversations Map staleness in a code comment; fix deferred to when dynamic group registration lands (ACTION-ITEMS item 17). No runtime behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
169
container/agent-runner/scripts/sdk-signal-probe.ts
Normal file
169
container/agent-runner/scripts/sdk-signal-probe.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* SDK signal probe: run a prompt, log every signal the Agent SDK emits —
|
||||
* async-iterator events + hook callbacks + CLI stderr — with absolute
|
||||
* and relative timing.
|
||||
*
|
||||
* Usage:
|
||||
* bun run scripts/sdk-signal-probe.ts "<prompt>" # simple string mode
|
||||
* bun run scripts/sdk-signal-probe.ts --stream "<prompt>" # streaming-input mode
|
||||
* bun run scripts/sdk-signal-probe.ts --stream "<p>" \
|
||||
* --push "5000:<text>" --push "15000:<text>" --timeout 60000 # multi-push
|
||||
*
|
||||
* Streaming mode (`--stream`) passes an AsyncIterable prompt to `query()`,
|
||||
* which keeps the CLI subprocess alive past the first result (per SDK
|
||||
* deep dive). Required for post-result pushes, agent teams, background
|
||||
* task notifications.
|
||||
*/
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const prompts: string[] = [];
|
||||
const pushes: Array<{ atMs: number; text: string }> = [];
|
||||
let streamMode = false;
|
||||
let timeoutMs: number | undefined;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--stream') streamMode = true;
|
||||
else if (a === '--push') {
|
||||
const val = args[++i] ?? '';
|
||||
const ix = val.indexOf(':');
|
||||
if (ix === -1) throw new Error(`bad --push (want MS:text): ${val}`);
|
||||
pushes.push({ atMs: parseInt(val.slice(0, ix), 10), text: val.slice(ix + 1) });
|
||||
} else if (a === '--timeout') timeoutMs = parseInt(args[++i] ?? '0', 10);
|
||||
else if (a === '--prompt') prompts.push(args[++i] ?? '');
|
||||
else prompts.push(a);
|
||||
}
|
||||
|
||||
const prompt = prompts.join(' ');
|
||||
if (!prompt) {
|
||||
console.error('usage: sdk-signal-probe.ts [--stream] "<prompt>" [--push MS:<text>]... [--timeout MS]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const T0 = Date.now();
|
||||
let LAST = T0;
|
||||
|
||||
function log(source: string, type: string, payload: unknown = {}): void {
|
||||
const now = Date.now();
|
||||
const entry = { t_ms: now - T0, d_ms: now - LAST, source, type, payload };
|
||||
LAST = now;
|
||||
console.log(JSON.stringify(entry));
|
||||
}
|
||||
|
||||
function hookLogger(eventName: string) {
|
||||
return async (input: unknown, toolUseID: string | undefined): Promise<any> => {
|
||||
log('hook', eventName, { toolUseID, input });
|
||||
// Stuck-tool simulation: if env flag is set and this is a PreToolUse for Bash,
|
||||
// never resolve — simulates a tool that hangs forever.
|
||||
if (process.env.PROBE_HANG === 'true' && eventName === 'PreToolUse') {
|
||||
const toolName = (input as any)?.tool_name ?? (input as any)?.name;
|
||||
if (toolName === 'Bash') {
|
||||
log('meta', 'pre_tool_use_hanging', { toolUseID, toolName });
|
||||
await new Promise(() => {
|
||||
/* never resolves */
|
||||
});
|
||||
}
|
||||
}
|
||||
return { continue: true };
|
||||
};
|
||||
}
|
||||
|
||||
const HOOK_EVENTS = [
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
'Notification',
|
||||
'UserPromptSubmit',
|
||||
'SessionStart',
|
||||
'SessionEnd',
|
||||
'Stop',
|
||||
'SubagentStart',
|
||||
'SubagentStop',
|
||||
'PreCompact',
|
||||
'PermissionRequest',
|
||||
] as const;
|
||||
|
||||
const hooks: Record<string, unknown[]> = {};
|
||||
for (const ev of HOOK_EVENTS) hooks[ev] = [{ hooks: [hookLogger(ev)] }];
|
||||
|
||||
// Build prompt — string (single-turn) or AsyncIterable (streaming-input)
|
||||
let promptInput: any;
|
||||
|
||||
if (streamMode) {
|
||||
const sessionId = `probe-${Date.now()}`;
|
||||
async function* streamPrompt() {
|
||||
// Initial user message
|
||||
yield {
|
||||
type: 'user' as const,
|
||||
message: { role: 'user' as const, content: prompt },
|
||||
parent_tool_use_id: null,
|
||||
session_id: sessionId,
|
||||
};
|
||||
// Schedule subsequent pushes
|
||||
const startT = Date.now();
|
||||
const sorted = [...pushes].sort((a, b) => a.atMs - b.atMs);
|
||||
for (const p of sorted) {
|
||||
const waitMs = Math.max(0, p.atMs - (Date.now() - startT));
|
||||
if (waitMs > 0) await new Promise((r) => setTimeout(r, waitMs));
|
||||
log('meta', 'push_message', { atMs: p.atMs, text: p.text });
|
||||
yield {
|
||||
type: 'user' as const,
|
||||
message: { role: 'user' as const, content: p.text },
|
||||
parent_tool_use_id: null,
|
||||
session_id: sessionId,
|
||||
};
|
||||
}
|
||||
// Keep stream open for tail events; iterator ends when we return
|
||||
// (no more work expected). For post-result-idle scenarios, wait here.
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
}
|
||||
promptInput = streamPrompt();
|
||||
} else {
|
||||
promptInput = prompt;
|
||||
}
|
||||
|
||||
log('meta', 'probe_start', { prompt, streamMode, pushes, timeoutMs });
|
||||
|
||||
const q = query({
|
||||
prompt: promptInput,
|
||||
options: {
|
||||
includePartialMessages: true,
|
||||
hooks: hooks as any,
|
||||
stderr: (data: string) => log('stderr', 'chunk', { data }),
|
||||
settingSources: [],
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Absolute time cap — exit cleanly so the log flushes
|
||||
if (timeoutMs) {
|
||||
setTimeout(() => {
|
||||
log('meta', 'timeout_hit', { timeoutMs });
|
||||
setTimeout(() => process.exit(0), 250);
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
try {
|
||||
for await (const event of q) {
|
||||
const snapshot: any = { ...event };
|
||||
try {
|
||||
const raw = JSON.stringify(snapshot);
|
||||
if (raw.length > 2000) {
|
||||
snapshot._truncated_bytes = raw.length;
|
||||
if (snapshot.message?.content) {
|
||||
const c = JSON.stringify(snapshot.message.content);
|
||||
snapshot.message = { ...snapshot.message, content: c.slice(0, 500) + `…<+${c.length - 500}b>` };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
log('event', snapshot.type ?? 'unknown', { subtype: snapshot.subtype, event: snapshot });
|
||||
}
|
||||
log('meta', 'iterator_done');
|
||||
} catch (err: any) {
|
||||
log('meta', 'iterator_error', { message: err?.message, stack: err?.stack?.split('\n').slice(0, 5) });
|
||||
}
|
||||
Reference in New Issue
Block a user