From 7642b697cabb95c3fb9ca1b4cb323eae02973445 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:59:14 +0900 Subject: [PATCH 01/72] =?UTF-8?q?=E8=A6=AA=E5=8F=97=E4=BF=A1=E3=81=AEcalle?= =?UTF-8?q?r=E7=9B=B8=E9=96=A2=E3=81=A8=E8=83=8C=E6=99=AF=E5=86=8D?= =?UTF-8?q?=E6=AD=A6=E8=A3=85=E3=82=92macOS=E3=81=A7=E5=AE=9F=E6=B8=AC?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- experiments/parent-background-wait-probe.mjs | 40 + .../parent-background-wait-probe.test.mjs | 50 + experiments/parent-caller-hook-probe.mjs | 128 ++ experiments/parent-caller-hook-probe.test.mjs | 73 + .../phase1-macos-caller-background.json | 1924 +++++++++++++++++ .../phase1-macos-caller-background.md | 51 + rag/parent-delivery/receivers.md | 2 + 7 files changed, 2268 insertions(+) create mode 100644 experiments/parent-background-wait-probe.mjs create mode 100644 experiments/parent-background-wait-probe.test.mjs create mode 100644 experiments/parent-caller-hook-probe.mjs create mode 100644 experiments/parent-caller-hook-probe.test.mjs create mode 100644 rag/parent-delivery/phase1-macos-caller-background.json create mode 100644 rag/parent-delivery/phase1-macos-caller-background.md diff --git a/experiments/parent-background-wait-probe.mjs b/experiments/parent-background-wait-probe.mjs new file mode 100644 index 00000000..709a2e95 --- /dev/null +++ b/experiments/parent-background-wait-probe.mjs @@ -0,0 +1,40 @@ +#!/usr/bin/env node +// 背景commandの完了が同じ親会話へ届くかを調べる。製品receiverの代用ではない。 +import { mkdir, readFile, writeFile, appendFile, readdir, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { randomUUID } from 'node:crypto' + +const [mode, root, id, body] = process.argv.slice(2) +const record = event => appendFile(join(root, 'background-events.jsonl'), `${JSON.stringify({ at: new Date().toISOString(), ...event })}\n`) +if (mode === 'inject') { + await mkdir(join(root, 'background-inbox'), { recursive: true }) + await writeFile(join(root, 'background-inbox', `${Date.now()}-${randomUUID()}.json`), JSON.stringify({ id, body })) + await record({ kind: 'injected', id, body }) +} else if (mode === 'wait') { + await mkdir(join(root, 'background-inbox'), { recursive: true }) + const slot = join(root, 'background-slot') + try { await mkdir(slot) } catch (error) { + if (error.code !== 'EEXIST') throw error + throw new Error('PROBE_SLOT_BUSY: 生きた待機と二重登録を区別して観測する') + } + const waiter = randomUUID() + await writeFile(join(slot, 'owner.json'), JSON.stringify({ waiter, pid: process.pid })) + await record({ kind: 'armed', waiter, pid: process.pid }) + const deadline = Date.now() + 120000 + let message + while (Date.now() < deadline) { + const files = (await readdir(join(root, 'background-inbox'))).filter(file => file.endsWith('.json')).sort() + if (files.length) { + const path = join(root, 'background-inbox', files[0]) + message = JSON.parse(await readFile(path, 'utf8')) + await rm(path) + break + } + await new Promise(resolve => setTimeout(resolve, 100)) + } + await rm(slot, { recursive: true }) + const result = message ? { outcome: 'message', room: '配送試験', from: 'probe', to: 'bell', seq: message.id, body: message.body, waiter } : { outcome: 'timeout', waiter } + await new Promise((resolve, reject) => process.stdout.write(`${JSON.stringify(result)}\n`, error => error ? reject(error) : resolve())) + await record({ kind: 'output', result }) + if (!message) process.exitCode = 3 +} else throw new Error('PROBE_MODE_INVALID: wait / injectを指定してください') diff --git a/experiments/parent-background-wait-probe.test.mjs b/experiments/parent-background-wait-probe.test.mjs new file mode 100644 index 00000000..f94be0fd --- /dev/null +++ b/experiments/parent-background-wait-probe.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { spawn } from 'node:child_process' +import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const script = fileURLToPath(new URL('./parent-background-wait-probe.mjs', import.meta.url)) +const run = (mode, root, ...args) => { + const child = spawn(process.execPath, [script, mode, root, ...args], { stdio: ['ignore', 'pipe', 'pipe'] }) + const completed = new Promise((resolve, reject) => { + let stdout = '' + let stderr = '' + child.stdout.on('data', value => { stdout += value }) + child.stderr.on('data', value => { stderr += value }) + child.on('error', reject) + child.on('close', code => resolve({ code, stdout, stderr })) + }) + return { child, completed } +} + +test('背景waitの1slot、本文保持、完了後の2回目登録', async t => { + const root = await mkdtemp(join(tmpdir(), 'peertable background test ')) + const children = [] + t.after(async () => { for (const child of children) child.kill(); await rm(root, { recursive: true, force: true }) }) + for (const id of ['1', '2']) { + const waiter = run('wait', root) + children.push(waiter.child) + // 外部processがslotを登録したことを待ち、起動要求だけを成功にしない。 + let armed = false + for (let i = 0; i < 100; i++) { + try { await readFile(join(root, 'background-slot/owner.json')); armed = true; break } catch (error) { if (error.code !== 'ENOENT') throw error } + await new Promise(resolve => setTimeout(resolve, 10)) + } + assert(armed, '受信processがslotを確保する') + const duplicate = await run('wait', root).completed + assert.notEqual(duplicate.code, 0) + assert.match(duplicate.stderr, /PROBE_SLOT_BUSY/) + assert.equal(duplicate.stdout, '') + const body = `符号${id} 日本語\n改行 "引用" ` + '長文'.repeat(3000) + assert.equal((await run('inject', root, id, body).completed).code, 0) + const delivered = await waiter.completed + assert.equal(delivered.code, 0) + const result = JSON.parse(delivered.stdout) + assert.equal(result.seq, id) + assert.equal(result.body, body) + assert.equal(result.outcome, 'message') + } +}) diff --git a/experiments/parent-caller-hook-probe.mjs b/experiments/parent-caller-hook-probe.mjs new file mode 100644 index 00000000..35471bb8 --- /dev/null +++ b/experiments/parent-caller-hook-probe.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +// 公式Cursor/Grokの事前hookが、実MCP入力へ相関を渡すかを観測する。 +import { mkdir, mkdtemp, readFile, writeFile, appendFile, rename } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { randomUUID, createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' + +const [mode, root, harness] = process.argv.slice(2) +const self = fileURLToPath(import.meta.url) +const record = (dir, value) => appendFile(join(dir, 'events.jsonl'), `${JSON.stringify({ at: new Date().toISOString(), ...value })}\n`) +const readJson = path => readFile(path, 'utf8').then(JSON.parse) +const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') +// hook設定のcommand欄は各OSの標準shellに適合する。 +const command = args => process.platform === 'win32' + ? `& ${args.map(arg => `'${arg.replaceAll("'", "''")}'`).join(' ')}` + : args.map(arg => `'${arg.replaceAll("'", "'\\''")}'`).join(' ') + +async function init(target) { + if (!['cursor', 'grok'].includes(target)) throw new Error('PROBE_HARNESS_INVALID') + const dir = await mkdtemp(join(tmpdir(), `peertable ${target} 本人性 `)) + execFileSync('git', ['init', '--quiet', dir]) + await mkdir(join(dir, 'contexts')) + const invocation = command([process.execPath, self, 'hook', dir, target]) + const mcp = { command: process.execPath, args: [self, 'mcp', dir, target] } + if (target === 'cursor') { + await mkdir(join(dir, '.cursor')) + await writeFile(join(dir, '.cursor/mcp.json'), JSON.stringify({ mcpServers: { peertable_probe: mcp } })) + await writeFile(join(dir, '.cursor/hooks.json'), JSON.stringify({ version: 1, hooks: { + preToolUse: [{ command: invocation }], postToolUse: [{ command: invocation }], + postToolUseFailure: [{ command: invocation }], afterMCPExecution: [{ command: invocation }], + stop: [{ command: invocation }], sessionEnd: [{ command: invocation }], + } })) + } else { + await mkdir(join(dir, '.grok/hooks'), { recursive: true }) + // Grok固有の正本へ置き、互換hookの二重読込みを避ける。 + await writeFile(join(dir, '.grok/config.toml'), `[mcp_servers.peertable_probe]\ncommand = ${JSON.stringify(mcp.command)}\nargs = ${JSON.stringify(mcp.args)}\n`) + await writeFile(join(dir, '.grok/hooks/peertable-probe.json'), JSON.stringify({ hooks: Object.fromEntries( + ['PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'Stop', 'SessionEnd'].map(event => [event, [{ hooks: [{ type: 'command', command: invocation }] }]]) + ) })) + } + const prompt = '受信本人性の小fixtureです。peertable_probeのparent_joinをlabel="同じ入力"で1回呼び、返却されたendpoint_idを使ってparent_readをlabel="同じ入力"で2回並列に呼び、その後parent_leaveを1回呼んでください。hook_context_idは入力しないでください。全結果のcodeだけ報告してください。ファイル編集や別のtoolは不要です。' + await writeFile(join(dir, 'prompt.txt'), prompt) + console.log(JSON.stringify({ root: dir, harness: target, prompt })) +} + +async function hook(dir, target) { + let raw = '' + for await (const chunk of process.stdin) raw += chunk + const event = JSON.parse(raw) + await record(dir, { kind: 'hook', harness: target, event }) + const pre = target === 'cursor' ? event.hook_event_name === 'preToolUse' : event.hook_event_name === 'PreToolUse' + const name = target === 'cursor' ? event.tool_name : event.toolName + const ours = target === 'cursor' ? /^MCP:parent_(join|read|leave)$/.test(name ?? '') : /peertable_probe.*parent_(join|read|leave)/.test(name ?? '') + if (!pre || !ours) { + console.log(pre && target === 'cursor' ? '{"permission":"allow"}' : '{}') + return + } + const input = target === 'cursor' ? event.tool_input : event.toolInput + const conversation = target === 'cursor' ? event.conversation_id : event.sessionId + const use = target === 'cursor' ? event.tool_use_id : event.toolUseId + if (!conversation || !use || !input || typeof input !== 'object') { + await record(dir, { kind: 'unbound', name }) + console.log(target === 'cursor' ? JSON.stringify({ permission: 'deny', agent_message: 'PARENT_CALLER_UNBOUND' }) : JSON.stringify({ decision: 'deny', reason: 'PARENT_CALLER_UNBOUND' })) + return + } + const id = randomUUID() + const argumentsInput = target === 'grok' ? input.tool_input : input + if (!argumentsInput || typeof argumentsInput !== 'object' || (target === 'grok' && input.tool_name !== name)) throw new Error('PROBE_MCP_INPUT_SHAPE_UNKNOWN') + await writeFile(join(dir, 'contexts', `${id}.json`), JSON.stringify({ id, conversation, use, name, input, digest: digest(argumentsInput) })) + const updated = target === 'grok' + ? { ...input, tool_input: { ...argumentsInput, hook_context_id: id } } + : { ...input, hook_context_id: id } + await record(dir, { kind: 'rewrite', id, conversation, use, name, input, updated }) + console.log(JSON.stringify(target === 'cursor' + ? { permission: 'allow', updated_input: updated } + : { hookSpecificOutput: { hookEventName: 'PreToolUse', updatedInput: updated } })) +} + +async function mcp(dir, target) { + const server = new Server({ name: 'peertable-caller-probe', version: '0.0.0' }, { capabilities: { tools: {} } }) + server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: ['parent_join', 'parent_read', 'parent_leave'].map(name => ({ + name, description: '公式事前hookと同じ会話の照合fixture', inputSchema: { type: 'object', properties: { + label: { type: 'string' }, endpoint_id: { type: 'string' }, hook_context_id: { type: 'string', description: '製品hook専用。モデルは省略する' }, + }, required: ['label'], additionalProperties: false }, + })) })) + server.setRequestHandler(CallToolRequestSchema, async request => { + const args = request.params.arguments ?? {} + const name = request.params.name + await record(dir, { kind: 'mcp_call', name, args, meta: request.params._meta ?? null, client: server.getClientVersion() }) + let code = 'PARENT_CALLER_UNBOUND' + let endpoint + const id = args.hook_context_id + if (typeof id === 'string' && /^[0-9a-f-]{36}$/.test(id)) { + try { + await rename(join(dir, 'contexts', `${id}.json`), join(dir, 'contexts', `${id}.consumed.json`)) + const context = await readJson(join(dir, 'contexts', `${id}.consumed.json`)) + const original = { ...args } + delete original.hook_context_id + if (context.digest !== digest(original) || !context.name.endsWith(name)) code = 'PARENT_CALLER_MISMATCH' + else if (name === 'parent_join') { + endpoint = { endpoint_id: randomUUID(), conversation: context.conversation } + await writeFile(join(dir, 'endpoint.json'), JSON.stringify(endpoint)) + code = 'PROBE_BOUND' + } else { + endpoint = await readJson(join(dir, 'endpoint.json')) + code = endpoint.conversation === context.conversation && endpoint.endpoint_id === args.endpoint_id ? 'PROBE_SAME_CALLER' : 'PARENT_CALLER_MISMATCH' + } + } catch (error) { + if (error.code !== 'ENOENT') throw error + code = 'PARENT_CALLER_UNBOUND' + } + } + const result = { code, endpoint_id: endpoint?.endpoint_id ?? null } + await record(dir, { kind: 'mcp_result', name, result }) + return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result, isError: !code.startsWith('PROBE_') } + }) + await server.connect(new StdioServerTransport()) +} + +if (mode === 'init') await init(root) +else if (mode === 'hook') await hook(root, harness) +else if (mode === 'mcp') await mcp(root, harness) +else throw new Error('PROBE_MODE_INVALID: init / hook / mcpを指定してください') diff --git a/experiments/parent-caller-hook-probe.test.mjs b/experiments/parent-caller-hook-probe.test.mjs new file mode 100644 index 00000000..6651bcce --- /dev/null +++ b/experiments/parent-caller-hook-probe.test.mjs @@ -0,0 +1,73 @@ +// 実測済みpayloadの形と、一回だけ消費するcaller相関を固定する。 +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { spawn } from 'node:child_process' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' + +const script = fileURLToPath(new URL('./parent-caller-hook-probe.mjs', import.meta.url)) +const runHook = (root, harness, event) => new Promise((resolve, reject) => { + const child = spawn(process.execPath, [script, 'hook', root, harness], { stdio: ['pipe', 'pipe', 'pipe'] }) + let output = '' + let error = '' + child.stdout.on('data', value => { output += value }) + child.stderr.on('data', value => { error += value }) + child.on('error', reject) + child.on('close', code => code ? reject(new Error(error)) : resolve(JSON.parse(output))) + child.stdin.end(JSON.stringify(event)) +}) + +for (const harness of ['cursor', 'grok']) test(`${harness}: 完全入力の保持・並行相関・別会話拒否・再消費拒否`, async t => { + const root = await mkdtemp(join(tmpdir(), 'peertable caller test ')) + await mkdir(join(root, 'contexts')) + const client = new Client({ name: 'fixture-test', version: '1' }) + const transport = new StdioClientTransport({ command: process.execPath, args: [script, 'mcp', root, harness] }) + t.after(async () => { await client.close(); await rm(root, { recursive: true, force: true }) }) + await client.connect(transport) + let generation = 0 + const rewrite = async (name, args, conversation = '会話A') => { + const event = harness === 'cursor' ? { + hook_event_name: 'preToolUse', tool_name: `MCP:${name}`, tool_input: args, conversation_id: conversation, tool_use_id: `tool-${++generation}`, + } : { + hook_event_name: 'PreToolUse', hookEventName: 'pre_tool_use', toolName: `peertable_probe__${name}`, + toolInput: { tool_name: `peertable_probe__${name}`, tool_input: args }, sessionId: conversation, toolUseId: `tool-${++generation}`, + } + const output = await runHook(root, harness, event) + const updated = harness === 'cursor' ? output.updated_input : output.hookSpecificOutput.updatedInput.tool_input + const original = { ...updated } + delete original.hook_context_id + assert.deepEqual(original, args, '元の全引数を保持する') + return updated + } + const call = async (name, args) => { + const result = await client.callTool({ name, arguments: args }) + return JSON.parse(result.content[0].text) + } + assert.equal((await call('parent_read', { label: '同じ入力' })).code, 'PARENT_CALLER_UNBOUND') + const joinArgs = await rewrite('parent_join', { label: '同じ入力' }) + const endpoint = await call('parent_join', joinArgs) + assert.equal(endpoint.code, 'PROBE_BOUND') + assert.equal((await call('parent_join', joinArgs)).code, 'PARENT_CALLER_UNBOUND') + const args = { label: '同じ入力', endpoint_id: endpoint.endpoint_id } + const [first, second] = await Promise.all([rewrite('parent_read', args), rewrite('parent_read', args)]) + assert.notEqual(first.hook_context_id, second.hook_context_id, '並行した同じ入力を別のtool useへ束縛する') + const results = await Promise.all([call('parent_read', first), call('parent_read', second)]) + assert(results.every(result => result.code === 'PROBE_SAME_CALLER')) + for (const name of ['parent_read', 'parent_leave']) { + const mismatch = await rewrite(name, args, '会話B') + assert.equal((await call(name, mismatch)).code, 'PARENT_CALLER_MISMATCH') + } + const tampered = await rewrite('parent_read', args) + assert.equal((await call('parent_read', { ...tampered, label: '改変' })).code, 'PARENT_CALLER_MISMATCH') + assert.equal((await readFile(join(root, 'endpoint.json'), 'utf8')).includes('会話A'), true) +}) + +test('Cursorの対象外preToolUseも正規のallow objectを返す', async t => { + const root = await mkdtemp(join(tmpdir(), 'peertable unrelated hook ')) + t.after(() => rm(root, { recursive: true, force: true })) + assert.deepEqual(await runHook(root, 'cursor', { hook_event_name: 'preToolUse', tool_name: 'Shell', tool_input: { command: 'true' } }), { permission: 'allow' }) +}) diff --git a/rag/parent-delivery/phase1-macos-caller-background.json b/rag/parent-delivery/phase1-macos-caller-background.json new file mode 100644 index 00000000..d7f1e8ea --- /dev/null +++ b/rag/parent-delivery/phase1-macos-caller-background.json @@ -0,0 +1,1924 @@ +{ + "schema": "peertable.parent-mechanism-probe.v1", + "observed_at": "2026-09-28", + "source_base": "2f1dcd9", + "aiterm_reference": "8cfe1da43a28606ccb53acd1dbfcc0607436add0", + "os": "darwin", + "native": true, + "node": "v26.10.0", + "product_acceptance": false, + "harnesses": { + "cursor": { + "version": "2026.09.26-dd393fe", + "model": "Auto", + "caller_session": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "background_session": "b66e8d43-7732-425a-b36d-6877cc7c5bfa", + "caller_events": [ + { + "at": "2026-09-28T13:45:46.199Z", + "kind": "hook", + "event": { + "hook_event_name": "preToolUse", + "conversation_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "generation_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "session_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "tool_name": "MCP:parent_join", + "tool_use_id": "1795b61f-5ef5-427b-9a50-140ea0681970", + "tool_input": { + "label": "同じ入力" + } + } + }, + { + "at": "2026-09-28T13:45:46.202Z", + "kind": "rewrite", + "id": "8cc52d6d-bc89-4d6d-b3a4-7343943af489", + "conversation": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "use": "1795b61f-5ef5-427b-9a50-140ea0681970", + "name": "MCP:parent_join", + "input": { + "label": "同じ入力" + }, + "updated": { + "label": "同じ入力", + "hook_context_id": "8cc52d6d-bc89-4d6d-b3a4-7343943af489" + } + }, + { + "at": "2026-09-28T13:45:46.227Z", + "kind": "mcp_call", + "name": "parent_join", + "args": { + "label": "同じ入力", + "hook_context_id": "8cc52d6d-bc89-4d6d-b3a4-7343943af489" + }, + "meta": null, + "client": { + "name": "Cursor", + "version": "1.0.0" + } + }, + { + "at": "2026-09-28T13:45:46.229Z", + "kind": "mcp_result", + "name": "parent_join", + "result": { + "code": "PROBE_BOUND", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b" + } + }, + { + "at": "2026-09-28T13:45:46.374Z", + "kind": "hook", + "event": { + "hook_event_name": "afterMCPExecution", + "conversation_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "generation_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "session_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "tool_name": "parent_join", + "tool_input": "{\"label\":\"同じ入力\",\"hook_context_id\":\"8cc52d6d-bc89-4d6d-b3a4-7343943af489\"}", + "result_json": "{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"code\\\":\\\"PROBE_BOUND\\\",\\\"endpoint_id\\\":\\\"3eef9fa9-9252-4b3c-b502-01c6fd02318b\\\"}\"}],\"isError\":false}" + } + }, + { + "at": "2026-09-28T13:45:46.583Z", + "kind": "hook", + "event": { + "hook_event_name": "postToolUse", + "conversation_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "generation_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "session_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "tool_name": "MCP:parent_join", + "tool_use_id": "1795b61f-5ef5-427b-9a50-140ea0681970", + "tool_input": { + "label": "同じ入力", + "hook_context_id": "8cc52d6d-bc89-4d6d-b3a4-7343943af489" + }, + "tool_output": "{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"code\\\":\\\"PROBE_BOUND\\\",\\\"endpoint_id\\\":\\\"3eef9fa9-9252-4b3c-b502-01c6fd02318b\\\"}\"}],\"isError\":false}" + } + }, + { + "at": "2026-09-28T13:45:49.392Z", + "kind": "hook", + "event": { + "hook_event_name": "preToolUse", + "conversation_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "generation_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "session_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "tool_name": "MCP:parent_read", + "tool_use_id": "a79009c6-19f9-426b-8c89-e69d118f92d1", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b" + } + } + }, + { + "at": "2026-09-28T13:45:49.395Z", + "kind": "rewrite", + "id": "a5de92ab-cb76-4bf1-8128-eaca1074b71e", + "conversation": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "use": "a79009c6-19f9-426b-8c89-e69d118f92d1", + "name": "MCP:parent_read", + "input": { + "label": "同じ入力", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b" + }, + "updated": { + "label": "同じ入力", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b", + "hook_context_id": "a5de92ab-cb76-4bf1-8128-eaca1074b71e" + } + }, + { + "at": "2026-09-28T13:45:49.419Z", + "kind": "mcp_call", + "name": "parent_read", + "args": { + "label": "同じ入力", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b", + "hook_context_id": "a5de92ab-cb76-4bf1-8128-eaca1074b71e" + }, + "meta": null, + "client": { + "name": "Cursor", + "version": "1.0.0" + } + }, + { + "at": "2026-09-28T13:45:49.419Z", + "kind": "mcp_result", + "name": "parent_read", + "result": { + "code": "PROBE_SAME_CALLER", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b" + } + }, + { + "at": "2026-09-28T13:45:49.681Z", + "kind": "hook", + "event": { + "hook_event_name": "afterMCPExecution", + "conversation_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "generation_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "session_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "tool_name": "parent_read", + "tool_input": "{\"label\":\"同じ入力\",\"endpoint_id\":\"3eef9fa9-9252-4b3c-b502-01c6fd02318b\",\"hook_context_id\":\"a5de92ab-cb76-4bf1-8128-eaca1074b71e\"}", + "result_json": "{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"code\\\":\\\"PROBE_SAME_CALLER\\\",\\\"endpoint_id\\\":\\\"3eef9fa9-9252-4b3c-b502-01c6fd02318b\\\"}\"}],\"isError\":false}" + } + }, + { + "at": "2026-09-28T13:45:49.885Z", + "kind": "hook", + "event": { + "hook_event_name": "preToolUse", + "conversation_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "generation_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "session_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "tool_name": "MCP:parent_read", + "tool_use_id": "bea1e151-6f0a-4c1c-8e52-b1f9fb40b4c4", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b" + } + } + }, + { + "at": "2026-09-28T13:45:49.892Z", + "kind": "rewrite", + "id": "22b07c2a-e1b8-498a-8e93-d552fadcb6aa", + "conversation": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "use": "bea1e151-6f0a-4c1c-8e52-b1f9fb40b4c4", + "name": "MCP:parent_read", + "input": { + "label": "同じ入力", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b" + }, + "updated": { + "label": "同じ入力", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b", + "hook_context_id": "22b07c2a-e1b8-498a-8e93-d552fadcb6aa" + } + }, + { + "at": "2026-09-28T13:45:49.911Z", + "kind": "hook", + "event": { + "hook_event_name": "postToolUse", + "conversation_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "generation_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "session_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "tool_name": "MCP:parent_read", + "tool_use_id": "a79009c6-19f9-426b-8c89-e69d118f92d1", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b", + "hook_context_id": "a5de92ab-cb76-4bf1-8128-eaca1074b71e" + }, + "tool_output": "{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"code\\\":\\\"PROBE_SAME_CALLER\\\",\\\"endpoint_id\\\":\\\"3eef9fa9-9252-4b3c-b502-01c6fd02318b\\\"}\"}],\"isError\":false}" + } + }, + { + "at": "2026-09-28T13:45:49.935Z", + "kind": "mcp_call", + "name": "parent_read", + "args": { + "label": "同じ入力", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b", + "hook_context_id": "22b07c2a-e1b8-498a-8e93-d552fadcb6aa" + }, + "meta": null, + "client": { + "name": "Cursor", + "version": "1.0.0" + } + }, + { + "at": "2026-09-28T13:45:49.937Z", + "kind": "mcp_result", + "name": "parent_read", + "result": { + "code": "PROBE_SAME_CALLER", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b" + } + }, + { + "at": "2026-09-28T13:45:50.107Z", + "kind": "hook", + "event": { + "hook_event_name": "afterMCPExecution", + "conversation_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "generation_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "session_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "tool_name": "parent_read", + "tool_input": "{\"label\":\"同じ入力\",\"endpoint_id\":\"3eef9fa9-9252-4b3c-b502-01c6fd02318b\",\"hook_context_id\":\"22b07c2a-e1b8-498a-8e93-d552fadcb6aa\"}", + "result_json": "{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"code\\\":\\\"PROBE_SAME_CALLER\\\",\\\"endpoint_id\\\":\\\"3eef9fa9-9252-4b3c-b502-01c6fd02318b\\\"}\"}],\"isError\":false}" + } + }, + { + "at": "2026-09-28T13:45:50.423Z", + "kind": "hook", + "event": { + "hook_event_name": "postToolUse", + "conversation_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "generation_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "session_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "tool_name": "MCP:parent_read", + "tool_use_id": "bea1e151-6f0a-4c1c-8e52-b1f9fb40b4c4", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b", + "hook_context_id": "22b07c2a-e1b8-498a-8e93-d552fadcb6aa" + }, + "tool_output": "{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"code\\\":\\\"PROBE_SAME_CALLER\\\",\\\"endpoint_id\\\":\\\"3eef9fa9-9252-4b3c-b502-01c6fd02318b\\\"}\"}],\"isError\":false}" + } + }, + { + "at": "2026-09-28T13:45:52.736Z", + "kind": "hook", + "event": { + "hook_event_name": "preToolUse", + "conversation_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "generation_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "session_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "tool_name": "MCP:parent_leave", + "tool_use_id": "137b2463-9090-4cea-bd23-9c095ee368fb", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b" + } + } + }, + { + "at": "2026-09-28T13:45:52.744Z", + "kind": "rewrite", + "id": "c60ca112-89e0-48e1-8f77-6732cc86ae58", + "conversation": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "use": "137b2463-9090-4cea-bd23-9c095ee368fb", + "name": "MCP:parent_leave", + "input": { + "label": "同じ入力", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b" + }, + "updated": { + "label": "同じ入力", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b", + "hook_context_id": "c60ca112-89e0-48e1-8f77-6732cc86ae58" + } + }, + { + "at": "2026-09-28T13:45:52.776Z", + "kind": "mcp_call", + "name": "parent_leave", + "args": { + "label": "同じ入力", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b", + "hook_context_id": "c60ca112-89e0-48e1-8f77-6732cc86ae58" + }, + "meta": null, + "client": { + "name": "Cursor", + "version": "1.0.0" + } + }, + { + "at": "2026-09-28T13:45:52.776Z", + "kind": "mcp_result", + "name": "parent_leave", + "result": { + "code": "PROBE_SAME_CALLER", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b" + } + }, + { + "at": "2026-09-28T13:45:52.934Z", + "kind": "hook", + "event": { + "hook_event_name": "afterMCPExecution", + "conversation_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "generation_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "session_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "tool_name": "parent_leave", + "tool_input": "{\"label\":\"同じ入力\",\"endpoint_id\":\"3eef9fa9-9252-4b3c-b502-01c6fd02318b\",\"hook_context_id\":\"c60ca112-89e0-48e1-8f77-6732cc86ae58\"}", + "result_json": "{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"code\\\":\\\"PROBE_SAME_CALLER\\\",\\\"endpoint_id\\\":\\\"3eef9fa9-9252-4b3c-b502-01c6fd02318b\\\"}\"}],\"isError\":false}" + } + }, + { + "at": "2026-09-28T13:45:53.134Z", + "kind": "hook", + "event": { + "hook_event_name": "postToolUse", + "conversation_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "generation_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "session_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "tool_name": "MCP:parent_leave", + "tool_use_id": "137b2463-9090-4cea-bd23-9c095ee368fb", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b", + "hook_context_id": "c60ca112-89e0-48e1-8f77-6732cc86ae58" + }, + "tool_output": "{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"code\\\":\\\"PROBE_SAME_CALLER\\\",\\\"endpoint_id\\\":\\\"3eef9fa9-9252-4b3c-b502-01c6fd02318b\\\"}\"}],\"isError\":false}" + } + }, + { + "at": "2026-09-28T13:45:59.051Z", + "kind": "hook", + "event": { + "hook_event_name": "sessionEnd", + "conversation_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "generation_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "session_id": "44d1911f-e78e-44fe-9692-8a8fcb282d53", + "reason": "completed" + } + }, + { + "at": "2026-09-28T13:47:02.540Z", + "kind": "hook", + "event": { + "hook_event_name": "preToolUse", + "conversation_id": "21a8d8c1-ad19-44ec-8004-f10d9860b1c0", + "generation_id": "21a8d8c1-ad19-44ec-8004-f10d9860b1c0", + "session_id": "21a8d8c1-ad19-44ec-8004-f10d9860b1c0", + "tool_name": "MCP:parent_read", + "tool_use_id": "03afd66a-cf79-44d6-8e4d-f2037ee289a2", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b" + } + } + }, + { + "at": "2026-09-28T13:47:02.543Z", + "kind": "rewrite", + "id": "fd5207f8-1baa-4d23-884d-515ed219b799", + "conversation": "21a8d8c1-ad19-44ec-8004-f10d9860b1c0", + "use": "03afd66a-cf79-44d6-8e4d-f2037ee289a2", + "name": "MCP:parent_read", + "input": { + "label": "同じ入力", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b" + }, + "updated": { + "label": "同じ入力", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b", + "hook_context_id": "fd5207f8-1baa-4d23-884d-515ed219b799" + } + }, + { + "at": "2026-09-28T13:47:02.571Z", + "kind": "mcp_call", + "name": "parent_read", + "args": { + "label": "同じ入力", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b", + "hook_context_id": "fd5207f8-1baa-4d23-884d-515ed219b799" + }, + "meta": null, + "client": { + "name": "Cursor", + "version": "1.0.0" + } + }, + { + "at": "2026-09-28T13:47:02.573Z", + "kind": "mcp_result", + "name": "parent_read", + "result": { + "code": "PARENT_CALLER_MISMATCH", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b" + } + }, + { + "at": "2026-09-28T13:47:02.744Z", + "kind": "hook", + "event": { + "hook_event_name": "afterMCPExecution", + "conversation_id": "21a8d8c1-ad19-44ec-8004-f10d9860b1c0", + "generation_id": "21a8d8c1-ad19-44ec-8004-f10d9860b1c0", + "session_id": "21a8d8c1-ad19-44ec-8004-f10d9860b1c0", + "tool_name": "parent_read", + "tool_input": "{\"label\":\"同じ入力\",\"endpoint_id\":\"3eef9fa9-9252-4b3c-b502-01c6fd02318b\",\"hook_context_id\":\"fd5207f8-1baa-4d23-884d-515ed219b799\"}", + "result_json": "{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"code\\\":\\\"PARENT_CALLER_MISMATCH\\\",\\\"endpoint_id\\\":\\\"3eef9fa9-9252-4b3c-b502-01c6fd02318b\\\"}\"}],\"isError\":true}" + } + }, + { + "at": "2026-09-28T13:47:02.877Z", + "kind": "hook", + "event": { + "hook_event_name": "preToolUse", + "conversation_id": "21a8d8c1-ad19-44ec-8004-f10d9860b1c0", + "generation_id": "21a8d8c1-ad19-44ec-8004-f10d9860b1c0", + "session_id": "21a8d8c1-ad19-44ec-8004-f10d9860b1c0", + "tool_name": "MCP:parent_leave", + "tool_use_id": "7b8d7a92-3f10-43cd-904a-3a91240a1473", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b" + } + } + }, + { + "at": "2026-09-28T13:47:02.882Z", + "kind": "rewrite", + "id": "1b13422f-6833-415c-b060-40c19f13b72f", + "conversation": "21a8d8c1-ad19-44ec-8004-f10d9860b1c0", + "use": "7b8d7a92-3f10-43cd-904a-3a91240a1473", + "name": "MCP:parent_leave", + "input": { + "label": "同じ入力", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b" + }, + "updated": { + "label": "同じ入力", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b", + "hook_context_id": "1b13422f-6833-415c-b060-40c19f13b72f" + } + }, + { + "at": "2026-09-28T13:47:02.920Z", + "kind": "mcp_call", + "name": "parent_leave", + "args": { + "label": "同じ入力", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b", + "hook_context_id": "1b13422f-6833-415c-b060-40c19f13b72f" + }, + "meta": null, + "client": { + "name": "Cursor", + "version": "1.0.0" + } + }, + { + "at": "2026-09-28T13:47:02.921Z", + "kind": "mcp_result", + "name": "parent_leave", + "result": { + "code": "PARENT_CALLER_MISMATCH", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b" + } + }, + { + "at": "2026-09-28T13:47:02.981Z", + "kind": "hook", + "event": { + "hook_event_name": "postToolUse", + "conversation_id": "21a8d8c1-ad19-44ec-8004-f10d9860b1c0", + "generation_id": "21a8d8c1-ad19-44ec-8004-f10d9860b1c0", + "session_id": "21a8d8c1-ad19-44ec-8004-f10d9860b1c0", + "tool_name": "MCP:parent_read", + "tool_use_id": "03afd66a-cf79-44d6-8e4d-f2037ee289a2", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b", + "hook_context_id": "fd5207f8-1baa-4d23-884d-515ed219b799" + }, + "tool_output": "{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"code\\\":\\\"PARENT_CALLER_MISMATCH\\\",\\\"endpoint_id\\\":\\\"3eef9fa9-9252-4b3c-b502-01c6fd02318b\\\"}\"}],\"isError\":true}" + } + }, + { + "at": "2026-09-28T13:47:03.114Z", + "kind": "hook", + "event": { + "hook_event_name": "afterMCPExecution", + "conversation_id": "21a8d8c1-ad19-44ec-8004-f10d9860b1c0", + "generation_id": "21a8d8c1-ad19-44ec-8004-f10d9860b1c0", + "session_id": "21a8d8c1-ad19-44ec-8004-f10d9860b1c0", + "tool_name": "parent_leave", + "tool_input": "{\"label\":\"同じ入力\",\"endpoint_id\":\"3eef9fa9-9252-4b3c-b502-01c6fd02318b\",\"hook_context_id\":\"1b13422f-6833-415c-b060-40c19f13b72f\"}", + "result_json": "{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"code\\\":\\\"PARENT_CALLER_MISMATCH\\\",\\\"endpoint_id\\\":\\\"3eef9fa9-9252-4b3c-b502-01c6fd02318b\\\"}\"}],\"isError\":true}" + } + }, + { + "at": "2026-09-28T13:47:03.338Z", + "kind": "hook", + "event": { + "hook_event_name": "postToolUse", + "conversation_id": "21a8d8c1-ad19-44ec-8004-f10d9860b1c0", + "generation_id": "21a8d8c1-ad19-44ec-8004-f10d9860b1c0", + "session_id": "21a8d8c1-ad19-44ec-8004-f10d9860b1c0", + "tool_name": "MCP:parent_leave", + "tool_use_id": "7b8d7a92-3f10-43cd-904a-3a91240a1473", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "3eef9fa9-9252-4b3c-b502-01c6fd02318b", + "hook_context_id": "1b13422f-6833-415c-b060-40c19f13b72f" + }, + "tool_output": "{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"code\\\":\\\"PARENT_CALLER_MISMATCH\\\",\\\"endpoint_id\\\":\\\"3eef9fa9-9252-4b3c-b502-01c6fd02318b\\\"}\"}],\"isError\":true}" + } + }, + { + "at": "2026-09-28T13:52:58.614Z", + "kind": "hook", + "event": { + "hook_event_name": "preToolUse", + "conversation_id": "b66e8d43-7732-425a-b36d-6877cc7c5bfa", + "generation_id": "38619d9a-8fb0-4e02-bfa9-260e3e0abe1d", + "session_id": "b66e8d43-7732-425a-b36d-6877cc7c5bfa", + "tool_name": "Shell", + "tool_use_id": "toolu_01HRqFrHzkW6SdE9fdvx3YBz", + "tool_input": { + "command": "'/opt/homebrew/bin/node' '/Users/kite/.codex/worktrees/aiterm-unified-delivery/peertable/experiments/parent-background-wait-probe.mjs' 'wait' '/var/folders/v4/ntdd_q2d10q962kq3cfx8lr00000gn/T/peertable cursor 本人性 gINTIv'", + "cwd": "" + } + } + }, + { + "at": "2026-09-28T13:53:09.547Z", + "kind": "hook", + "event": { + "hook_event_name": "postToolUse", + "conversation_id": "b66e8d43-7732-425a-b36d-6877cc7c5bfa", + "generation_id": "38619d9a-8fb0-4e02-bfa9-260e3e0abe1d", + "session_id": "b66e8d43-7732-425a-b36d-6877cc7c5bfa", + "tool_name": "Shell", + "tool_use_id": "toolu_01HRqFrHzkW6SdE9fdvx3YBz", + "tool_input": { + "command": "'/opt/homebrew/bin/node' '/Users/kite/.codex/worktrees/aiterm-unified-delivery/peertable/experiments/parent-background-wait-probe.mjs' 'wait' '/var/folders/v4/ntdd_q2d10q962kq3cfx8lr00000gn/T/peertable cursor 本人性 gINTIv'", + "cwd": "" + }, + "tool_output": "{\"shell_id\":185626,\"pid\":52159}" + } + }, + { + "at": "2026-09-28T13:53:11.752Z", + "kind": "hook", + "event": { + "hook_event_name": "stop", + "conversation_id": "b66e8d43-7732-425a-b36d-6877cc7c5bfa", + "generation_id": "38619d9a-8fb0-4e02-bfa9-260e3e0abe1d", + "session_id": "b66e8d43-7732-425a-b36d-6877cc7c5bfa" + } + }, + { + "at": "2026-09-28T13:53:53.632Z", + "kind": "hook", + "event": { + "hook_event_name": "preToolUse", + "conversation_id": "b66e8d43-7732-425a-b36d-6877cc7c5bfa", + "generation_id": "f7b152fd-1533-4a35-a822-cdb6c396159c", + "session_id": "b66e8d43-7732-425a-b36d-6877cc7c5bfa", + "tool_name": "Read", + "tool_use_id": "toolu_019UYqmSKvtM6D6F5tkY9S16", + "tool_input": { + "file_path": "/Users/kite/.cursor/projects/private-var-folders-v4-ntdd-q2d10q962kq3cfx8lr00000gn-T-peertable-cursor-gINTIv/terminals/185626.txt" + } + } + }, + { + "at": "2026-09-28T13:53:53.869Z", + "kind": "hook", + "event": { + "hook_event_name": "postToolUse", + "conversation_id": "b66e8d43-7732-425a-b36d-6877cc7c5bfa", + "generation_id": "f7b152fd-1533-4a35-a822-cdb6c396159c", + "session_id": "b66e8d43-7732-425a-b36d-6877cc7c5bfa", + "tool_name": "Read", + "tool_use_id": "toolu_019UYqmSKvtM6D6F5tkY9S16", + "tool_input": { + "file_path": "/Users/kite/.cursor/projects/private-var-folders-v4-ntdd-q2d10q962kq3cfx8lr00000gn-T-peertable-cursor-gINTIv/terminals/185626.txt" + }, + "tool_output": "{\"file_path\":\"/Users/kite/.cursor/projects/private-var-folders-v4-ntdd-q2d10q962kq3cfx8lr00000gn-T-peertable-cursor-gINTIv/terminals/185626.txt\",\"content_length\":695}" + } + }, + { + "at": "2026-09-28T13:53:56.381Z", + "kind": "hook", + "event": { + "hook_event_name": "preToolUse", + "conversation_id": "b66e8d43-7732-425a-b36d-6877cc7c5bfa", + "generation_id": "f7b152fd-1533-4a35-a822-cdb6c396159c", + "session_id": "b66e8d43-7732-425a-b36d-6877cc7c5bfa", + "tool_name": "Shell", + "tool_use_id": "toolu_019uEe5Z4ZXQmE3o2nSWuzt5", + "tool_input": { + "command": "'/opt/homebrew/bin/node' '/Users/kite/.codex/worktrees/aiterm-unified-delivery/peertable/experiments/parent-background-wait-probe.mjs' 'wait' '/var/folders/v4/ntdd_q2d10q962kq3cfx8lr00000gn/T/peertable cursor 本人性 gINTIv'", + "cwd": "" + } + } + }, + { + "at": "2026-09-28T13:53:56.668Z", + "kind": "hook", + "event": { + "hook_event_name": "postToolUse", + "conversation_id": "b66e8d43-7732-425a-b36d-6877cc7c5bfa", + "generation_id": "f7b152fd-1533-4a35-a822-cdb6c396159c", + "session_id": "b66e8d43-7732-425a-b36d-6877cc7c5bfa", + "tool_name": "Shell", + "tool_use_id": "toolu_019uEe5Z4ZXQmE3o2nSWuzt5", + "tool_input": { + "command": "'/opt/homebrew/bin/node' '/Users/kite/.codex/worktrees/aiterm-unified-delivery/peertable/experiments/parent-background-wait-probe.mjs' 'wait' '/var/folders/v4/ntdd_q2d10q962kq3cfx8lr00000gn/T/peertable cursor 本人性 gINTIv'", + "cwd": "" + }, + "tool_output": "{\"shell_id\":185627,\"pid\":53666}" + } + }, + { + "at": "2026-09-28T13:53:59.092Z", + "kind": "hook", + "event": { + "hook_event_name": "stop", + "conversation_id": "b66e8d43-7732-425a-b36d-6877cc7c5bfa", + "generation_id": "f7b152fd-1533-4a35-a822-cdb6c396159c", + "session_id": "b66e8d43-7732-425a-b36d-6877cc7c5bfa" + } + }, + { + "at": "2026-09-28T13:54:30.892Z", + "kind": "hook", + "event": { + "hook_event_name": "preToolUse", + "conversation_id": "b66e8d43-7732-425a-b36d-6877cc7c5bfa", + "generation_id": "00c80f31-fd09-493f-81e1-3379fa1d7428", + "session_id": "b66e8d43-7732-425a-b36d-6877cc7c5bfa", + "tool_name": "Read", + "tool_use_id": "toolu_01LgD16qpRRUJHiyFPA6Fq3e", + "tool_input": { + "file_path": "/Users/kite/.cursor/projects/private-var-folders-v4-ntdd-q2d10q962kq3cfx8lr00000gn-T-peertable-cursor-gINTIv/terminals/185627.txt" + } + } + }, + { + "at": "2026-09-28T13:54:31.117Z", + "kind": "hook", + "event": { + "hook_event_name": "postToolUse", + "conversation_id": "b66e8d43-7732-425a-b36d-6877cc7c5bfa", + "generation_id": "00c80f31-fd09-493f-81e1-3379fa1d7428", + "session_id": "b66e8d43-7732-425a-b36d-6877cc7c5bfa", + "tool_name": "Read", + "tool_use_id": "toolu_01LgD16qpRRUJHiyFPA6Fq3e", + "tool_input": { + "file_path": "/Users/kite/.cursor/projects/private-var-folders-v4-ntdd-q2d10q962kq3cfx8lr00000gn-T-peertable-cursor-gINTIv/terminals/185627.txt" + }, + "tool_output": "{\"file_path\":\"/Users/kite/.cursor/projects/private-var-folders-v4-ntdd-q2d10q962kq3cfx8lr00000gn-T-peertable-cursor-gINTIv/terminals/185627.txt\",\"content_length\":693}" + } + }, + { + "at": "2026-09-28T13:54:35.310Z", + "kind": "hook", + "event": { + "hook_event_name": "stop", + "conversation_id": "b66e8d43-7732-425a-b36d-6877cc7c5bfa", + "generation_id": "00c80f31-fd09-493f-81e1-3379fa1d7428", + "session_id": "b66e8d43-7732-425a-b36d-6877cc7c5bfa" + } + } + ], + "background_wait_events": [ + { + "at": "2026-09-28T13:53:09.471Z", + "kind": "armed", + "waiter": "b18317cb-bf34-48fe-bd97-fbae2a3614a1", + "pid": 52190 + }, + { + "at": "2026-09-28T13:53:50.739Z", + "kind": "injected", + "id": "1", + "body": "符号 CURSOR_BACKGROUND_ONE_日本語" + }, + { + "at": "2026-09-28T13:53:50.788Z", + "kind": "output", + "result": { + "outcome": "message", + "room": "配送試験", + "from": "probe", + "to": "bell", + "seq": "1", + "body": "符号 CURSOR_BACKGROUND_ONE_日本語", + "waiter": "b18317cb-bf34-48fe-bd97-fbae2a3614a1" + } + }, + { + "at": "2026-09-28T13:53:56.618Z", + "kind": "armed", + "waiter": "ba0b6362-accf-43b8-b8ad-d9981c906979", + "pid": 53699 + }, + { + "at": "2026-09-28T13:54:24.589Z", + "kind": "injected", + "id": "2", + "body": "符号 CURSOR_BACKGROUND_TWO_日本語" + }, + { + "at": "2026-09-28T13:54:24.667Z", + "kind": "output", + "result": { + "outcome": "message", + "room": "配送試験", + "from": "probe", + "to": "bell", + "seq": "2", + "body": "符号 CURSOR_BACKGROUND_TWO_日本語", + "waiter": "ba0b6362-accf-43b8-b8ad-d9981c906979" + } + } + ], + "assistant_observed": [ + "1件目が届きました。seq は 1、本文の符号は CURSOR_BACKGROUND_ONE_日本語です。", + "2件目も届いて、probeはこれで終わりです。seq は 2、本文の符号は CURSOR_BACKGROUND_TWO_日本語でした。" + ], + "same_caller": true, + "other_caller_rejected": true, + "native_parallel_reads": false, + "idle_background_two_messages": true, + "product_spool": false + }, + "grok": { + "version": "1.0.41 (4220f3b224a6)", + "model": "Grok 4.6 (high)", + "caller_session": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "background_session": "01a0e84a-238b-76e1-b7cd-c14069a20945", + "caller_events": [ + { + "at": "2026-09-28T13:49:32.059Z", + "kind": "hook", + "event": { + "hook_event_name": "PreToolUse", + "hookEventName": "pre_tool_use", + "sessionId": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "session_id": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "tool_name": "search_tool", + "toolName": "search_tool", + "tool_use_id": "call-2ac48082-a10f-4487-9c87-257c4a49c177-0", + "toolUseId": "call-2ac48082-a10f-4487-9c87-257c4a49c177-0", + "tool_input": { + "query": "peertable_probe parent_join parent_read parent_leave", + "limit": 10 + }, + "toolInput": { + "query": "peertable_probe parent_join parent_read parent_leave", + "limit": 10 + } + } + }, + { + "at": "2026-09-28T13:49:32.195Z", + "kind": "hook", + "event": { + "hook_event_name": "PostToolUse", + "hookEventName": "post_tool_use", + "sessionId": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "session_id": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "tool_name": "search_tool", + "toolName": "search_tool", + "tool_use_id": "call-2ac48082-a10f-4487-9c87-257c4a49c177-0", + "toolUseId": "call-2ac48082-a10f-4487-9c87-257c4a49c177-0", + "tool_input": { + "query": "peertable_probe parent_join parent_read parent_leave", + "limit": 10 + }, + "toolInput": { + "query": "peertable_probe parent_join parent_read parent_leave", + "limit": 10 + }, + "toolResult": { + "type": "SearchTool", + "result_count": 10, + "content": "{\n \"results\": [\n {\n \"server\": \"peertable_probe\",\n \"tools\": [\n {\n \"tool_name\": \"peertable_probe__parent_join\",\n \"description\": \"公式事前hookと同じ会話の照合fixture\",\n \"score\": 38.88677215576172,\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"label\": {\n \"type\": \"string\"\n },\n \"endpoint_id\": {\n \"type\": \"string\"\n },\n \"hook_context_id\": {\n \"type\": \"string\",\n \"description\": \"製品hook専用。モデルは省略する\"\n }\n },\n \"required\": [\n \"label\"\n ],\n \"additionalProperties\": false\n }\n },\n {\n \"tool_name\": \"peertable_probe__parent_leave\",\n \"description\": \"公式事前hookと同じ会話の照合fixture\",\n \"score\": 37.82375717163086,\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"label\": {\n \"type\": \"string\"\n },\n \"endpoint_id\": {\n \"type\": \"string\"\n },\n \"hook_context_id\": {\n \"type\": \"string\",\n \"description\": \"製品hook専用。モデルは省略する\"\n }\n },\n \"required\": [\n \"label\"\n ],\n \"additionalProperties\": false\n }\n },\n {\n \"tool_name\": \"peertable_probe__parent_read\",\n \"description\": \"公式事前hookと同じ会話の照合fixture\",\n \"score\": 35.804561614990234,\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"label\": {\n \"type\": \"string\"\n },\n \"endpoint_id\": {\n \"type\": \"string\"\n },\n \"hook_context_id\": {\n \"type\": \"string\",\n \"description\": \"製品hook専用。モデルは省略する\"\n }\n },\n \"required\": [\n \"label\"\n ],\n \"additionalProperties\": false\n }\n }\n ]\n },\n {\n \"server\": \"x-article\",\n \"tools\": [\n {\n \"tool_name\": \"x-article__x_like\",\n \"description\": \"Like a tweet (FavoriteTweet). Pass the numeric tweet id. Already liked (FavoriteTweet code 139) returns { success: false, alreadyLiked: true, tweetId } instead of throwing. Prefer likedByMe on read tools over probing with x_like.\",\n \"score\": 5.607769012451172,\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"account\": {\n \"type\": \"string\",\n \"enum\": [\n \"クオ\",\n \"ベル\"\n ],\n \"description\": \"X account. Required. No default. Registered now: クオ, ベル. Use one of these names. If empty, call x_article_set_credentials or x_account_adopt first.\"\n },\n \"tweetId\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"Tweet rest_id to like\"\n }\n },\n \"required\": [\n \"account\",\n \"tweetId\"\n ],\n \"additionalProperties\": false,\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n }\n },\n {\n \"tool_name\": \"x-article__x_home_timeline\",\n \"description\": \"Read the home timeline (Following or For You). Returns a compact tweet list (tweetId, text, screenName, url, likedByMe, following, followedBy, followRequestSent, badgeType, followersCount) plus nextCursor for pagination. Does not include promoted posts. count is timeline slots, not flattened replies. likedByMe / following are boolean or null when X omitted the field — do not probe with x_like / x_follow.\",\n \"score\": 4.837660789489746,\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"account\": {\n \"type\": \"string\",\n \"enum\": [\n \"クオ\",\n \"ベル\"\n ],\n \"description\": \"X account. Required. No default. Registered now: クオ, ベル. Use one of these names. If empty, call x_article_set_credentials or x_account_adopt first.\"\n },\n \"tab\": {\n \"type\": \"string\",\n \"enum\": [\n \"following\",\n \"foryou\"\n ],\n \"description\": \"following = HomeLatestTimeline (default). foryou = HomeTimeline (ranked).\"\n },\n \"count\": {\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 40,\n \"description\": \"Requested X page size, 1–40. Default 20. X may return more entries in a page.\"\n },\n \"cursor\": {\n \"type\": \"string\",\n \"description\": \"nextCursor from a previous page.\"\n }\n },\n \"required\": [\n \"account\"\n ],\n \"additionalProperties\": false,\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n }\n },\n {\n \"tool_name\": \"x-article__x_follow\",\n \"description\": \"Follow a user (REST 1.1 friendships/create). Provide EXACTLY one of `userId` (numeric rest_id) or `screenName` (@handle; looked up via UserByScreenName). HTTP 200 means the follow was accepted: { success: true, following: true }. create is async, so a stale following=false on the user object is not a failure. reason is only set on a real reject (pending / blocked / protected / restricted / rate_limited / unknown). HTTP 429 is { success: false, rateLimited: true, retryAfterSec?, code: 88 }. Prefer following on read tools over probing with x_follow.\",\n \"score\": 4.193950653076172,\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"account\": {\n \"type\": \"string\",\n \"enum\": [\n \"クオ\",\n \"ベル\"\n ],\n \"description\": \"X account. Required. No default. Registered now: クオ, ベル. Use one of these names. If empty, call x_article_set_credentials or x_account_adopt first.\"\n },\n \"userId\": {\n \"type\": \"string\",\n \"description\": \"Target user rest_id (digits only). Use this OR screenName.\"\n },\n \"screenName\": {\n \"type\": \"string\",\n \"description\": \"Target @handle without requiring the @. Use this OR userId.\"\n }\n },\n \"required\": [\n \"account\"\n ],\n \"additionalProperties\": false,\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n }\n },\n {\n \"tool_name\": \"x-article__x_article_set_credentials\",\n \"description\": \"Store X session cookies (auth_token, ct0, twid) **encrypted** on this server under a named account. These values never leave the machine running the MCP server. If unnamed legacy credentials exist, call x_account_adopt first.\",\n \"score\": 3.9133150577545166,\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"account\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"Account name to create or overwrite. Letters in any script, digits, \\\".\\\", \\\"_\\\" or \\\"-\\\". Required. No default.\"\n },\n \"authToken\": {\n \"type\": \"string\",\n \"minLength\": 10,\n \"description\": \"Value of the auth_token cookie from x.com\"\n },\n \"ct0\": {\n \"type\": \"string\",\n \"minLength\": 10,\n \"description\": \"Value of the ct0 cookie (also sent as x-csrf-token header)\"\n },\n \"twid\": {\n \"type\": \"string\",\n \"description\": \"Optional twid cookie (usually starts with u%3D)\"\n }\n },\n \"required\": [\n \"account\",\n \"authToken\",\n \"ct0\"\n ],\n \"additionalProperties\": false,\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n }\n },\n {\n \"tool_name\": \"x-article__x_article_revise\",\n \"description\": \"Edit an article that may already be published. Direct UpdateTitle on a published article returns GraphQL 214 — there is no published-edit mutation in this client-web bundle. This tool tries the draft updates first; on 214 it Unpublishes, applies title/markdown, then Publishes again (set republish:false to leave it as a draft). Same ops as the granular tools; does not invent a queryId. Provide title and/or markdown.\",\n \"score\": 3.370596408843994,\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"account\": {\n \"type\": \"string\",\n \"enum\": [\n \"クオ\",\n \"ベル\"\n ],\n \"description\": \"X account. Required. No default. Registered now: クオ, ベル. Use one of these names. If empty, call x_article_set_credentials or x_account_adopt first.\"\n },\n \"articleEntityId\": {\n \"type\": \"string\"\n },\n \"title\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"markdown\": {\n \"type\": \"string\",\n \"description\": \"Replacement body as Markdown. Omit to leave body unchanged.\"\n },\n \"republish\": {\n \"type\": \"boolean\",\n \"default\": true,\n \"description\": \"After an unpublish-to-edit, publish again. Default true. Ignored if it was already a draft.\"\n }\n },\n \"required\": [\n \"account\",\n \"articleEntityId\"\n ],\n \"additionalProperties\": false,\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n }\n }\n ]\n },\n {\n \"server\": \"lattice\",\n \"tools\": [\n {\n \"tool_name\": \"lattice__lattice_sensor_node\",\n \"description\": \"Two modes. (1) READ A FILE — use INSTEAD of the Read tool: pass `file` (a path or basename) with no `symbol` and it returns that file's current on-disk source with line numbers, exactly the shape Read gives you (`\\\\t`, safe to Edit from), narrowable with `offset`/`limit` just like Read — PLUS a one-line note of which files depend on it. Same bytes as Read, faster (served from the index), with the blast radius attached. Use it whenever you would Read a source file. (2) ONE SYMBOL you can name — its location, signature, verbatim source (includeCode=true) and caller/callee trail in one call, so before changing it you see what calls it and what your edit would break. For an AMBIGUOUS name it returns EVERY matching definition's body in one call (so you never Read a file to find the right overload); pass `file`/`line` to pin one. Use lattice_sensor_explore for several related symbols or the full flow.\",\n \"score\": 3.4175596237182617,\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"symbol\": {\n \"type\": \"string\",\n \"description\": \"Name of the symbol to read (symbol mode). Omit it and pass `file` alone to read a whole file like Read.\"\n },\n \"includeCode\": {\n \"type\": \"boolean\",\n \"description\": \"Symbol mode: include the symbol's full body (default: false). Ignored in file mode, which always returns source unless `symbolsOnly` is set.\",\n \"default\": false\n },\n \"file\": {\n \"type\": \"string\",\n \"description\": \"A file path or basename (e.g. \\\"harness.rs\\\", \\\"src/auth/session.ts\\\"). Pass it ALONE (no symbol) to READ the file like the Read tool — its full source with line numbers + which files depend on it. Or pass it WITH a symbol to disambiguate an overloaded name to the definition in this file.\"\n },\n \"offset\": {\n \"type\": \"number\",\n \"description\": \"File mode: 1-based line to start reading from, exactly like Read's offset. Defaults to the start of the file.\"\n },\n \"limit\": {\n \"type\": \"number\",\n \"description\": \"File mode: maximum number of lines to return, exactly like Read's limit. Defaults to the whole file (capped at 2000 lines, like Read).\"\n },\n \"symbolsOnly\": {\n \"type\": \"boolean\",\n \"description\": \"File mode: return just the file's symbol map + dependents (a cheap structural overview) instead of its source.\",\n \"default\": false\n },\n \"line\": {\n \"type\": \"number\",\n \"description\": \"Symbol mode only: disambiguate to the definition at/around this line (use with the file:line a trail showed you).\"\n },\n \"projectPath\": {\n \"type\": \"string\",\n \"description\": \"Absolute path to the project to query (or any directory inside it) — lattice sensor uses the nearest .lattice/sensor/ index at or above that path. Omit to use this session's default project. Pass it to query a second codebase, or when the server root has no index of its own (e.g. a monorepo where only sub-projects are indexed, so there is no default project).\"\n }\n },\n \"required\": [\n \"projectPath\"\n ]\n }\n }\n ]\n },\n {\n \"server\": \"aishell\",\n \"tools\": [\n {\n \"tool_name\": \"aishell__artifact_read\",\n \"description\": \"保持済みrun log/artifactの検索・比較・budget読取はsource file読取でなくこれを使う。 Use this to search, compare, or budget-read retained run logs and artifacts, not source files.\",\n \"score\": 3.254379987716675,\n \"input_schema\": {\n \"oneOf\": [\n {\n \"additionalProperties\": false,\n \"properties\": {\n \"byte_budget\": {\n \"maximum\": 1048576,\n \"minimum\": 1,\n \"type\": \"integer\"\n },\n \"context_lines\": {\n \"minimum\": 0,\n \"type\": \"integer\"\n },\n \"handle\": {\n \"type\": \"string\"\n },\n \"length\": {\n \"minimum\": 0,\n \"type\": \"integer\"\n },\n \"mode\": {\n \"enum\": [\n \"range\",\n \"tail\",\n \"around\"\n ],\n \"type\": \"string\"\n },\n \"offset\": {\n \"minimum\": 0,\n \"type\": \"integer\"\n },\n \"pattern\": {\n \"type\": \"string\"\n },\n \"tail_lines\": {\n \"minimum\": 1,\n \"type\": \"integer\"\n }\n },\n \"required\": [\n \"handle\"\n ],\n \"type\": \"object\"\n },\n {\n \"additionalProperties\": false,\n \"properties\": {\n \"action\": {\n \"const\": \"search\",\n \"type\": \"string\"\n },\n \"case\": {\n \"enum\": [\n \"sensitive\",\n \"insensitive\"\n ],\n \"type\": \"string\"\n },\n \"page_byte_limit\": {\n \"maximum\": 1048576,\n \"minimum\": 1,\n \"type\": \"integer\"\n },\n \"pattern\": {\n \"maxLength\": 65536,\n \"minLength\": 1,\n \"type\": \"string\"\n },\n \"pattern_kind\": {\n \"enum\": [\n \"literal\",\n \"regex\"\n ],\n \"type\": \"string\"\n },\n \"project_path\": {\n \"maxLength\": 4096,\n \"minLength\": 1,\n \"type\": \"string\"\n },\n \"regex_flags\": {\n \"maxLength\": 8,\n \"minLength\": 0,\n \"type\": \"string\"\n },\n \"sources\": {\n \"items\": {\n \"oneOf\": [\n {\n \"additionalProperties\": false,\n \"properties\": {\n \"handle\": {\n \"maxLength\": 16384,\n \"minLength\": 1,\n \"type\": \"string\"\n },\n \"type\": {\n \"const\": \"artifact\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"handle\"\n ],\n \"type\": \"object\"\n },\n {\n \"additionalProperties\": false,\n \"properties\": {\n \"channels\": {\n \"items\": {\n \"enum\": [\n \"stdout\",\n \"stderr\",\n \"diagnostics\"\n ],\n \"type\": \"string\"\n },\n \"maxItems\": 3,\n \"minItems\": 1,\n \"type\": \"array\",\n \"uniqueItems\": true\n },\n \"run_id\": {\n \"maxLength\": 64,\n \"minLength\": 1,\n \"type\": \"string\"\n },\n \"type\": {\n \"const\": \"run\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"run_id\",\n \"channels\"\n ],\n \"type\": \"object\"\n }\n ]\n },\n \"maxItems\": 64,\n \"minItems\": 1,\n \"type\": \"array\"\n }\n },\n \"required\": [\n \"action\",\n \"project_path\",\n \"sources\",\n \"pattern\"\n ],\n \"type\": \"object\"\n },\n {\n \"additionalProperties\": false,\n \"properties\": {\n \"action\": {\n \"const\": \"next\",\n \"type\": \"string\"\n },\n \"cursor\": {\n \"maxLength\": 65536,\n \"minLength\": 1,\n \"type\": \"string\"\n },\n \"page_byte_limit\": {\n \"maximum\": 1048576,\n \"minimum\": 1,\n \"type\": \"integer\"\n },\n \"stream_handle\": {\n \"maxLength\": 16384,\n \"minLength\": 1,\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"action\",\n \"stream_handle\",\n \"cursor\"\n ],\n \"type\": \"object\"\n },\n {\n \"additionalProperties\": false,\n \"properties\": {\n \"action\": {\n \"const\": \"compare\",\n \"type\": \"string\"\n },\n \"baseline_run_id\": {\n \"maxLength\": 64,\n \"minLength\": 1,\n \"type\": \"string\"\n },\n \"candidate_run_id\": {\n \"maxLength\": 64,\n \"minLength\": 1,\n \"type\": \"string\"\n },\n \"channels\": {\n \"items\": {\n \"enum\": [\n \"stdout\",\n \"stderr\",\n \"diagnostics\"\n ],\n \"type\": \"string\"\n },\n \"maxItems\": 3,\n \"minItems\": 1,\n \"type\": \"array\",\n \"uniqueItems\": true\n },\n \"project_path\": {\n \"maxLength\": 4096,\n \"minLength\": 1,\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"action\",\n \"project_path\",\n \"baseline_run_id\",\n \"candidate_run_id\",\n \"channels\"\n ],\n \"type\": \"object\"\n }\n ],\n \"type\": \"object\",\n \"properties\": {}\n }\n }\n ]\n }\n ],\n \"total_hidden_tools\": 149,\n \"status\": \"ready\",\n \"note\": null\n}" + } + } + }, + { + "at": "2026-09-28T13:49:35.153Z", + "kind": "hook", + "event": { + "hook_event_name": "PreToolUse", + "hookEventName": "pre_tool_use", + "sessionId": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "session_id": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "tool_name": "peertable_probe__parent_join", + "toolName": "peertable_probe__parent_join", + "tool_use_id": "call-5ba882b3-83e8-4ea0-bab4-59ffd1bbc09d-1", + "toolUseId": "call-5ba882b3-83e8-4ea0-bab4-59ffd1bbc09d-1", + "tool_input": { + "tool_name": "peertable_probe__parent_join", + "tool_input": { + "label": "同じ入力" + } + }, + "toolInput": { + "tool_name": "peertable_probe__parent_join", + "tool_input": { + "label": "同じ入力" + } + } + } + }, + { + "at": "2026-09-28T13:49:35.156Z", + "kind": "rewrite", + "id": "22d2a8a9-cc52-4c65-a392-52a625fb9a3e", + "conversation": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "use": "call-5ba882b3-83e8-4ea0-bab4-59ffd1bbc09d-1", + "name": "peertable_probe__parent_join", + "input": { + "tool_name": "peertable_probe__parent_join", + "tool_input": { + "label": "同じ入力" + } + }, + "updated": { + "tool_name": "peertable_probe__parent_join", + "tool_input": { + "label": "同じ入力", + "hook_context_id": "22d2a8a9-cc52-4c65-a392-52a625fb9a3e" + } + } + }, + { + "at": "2026-09-28T13:49:35.163Z", + "kind": "mcp_call", + "name": "parent_join", + "args": { + "label": "同じ入力", + "hook_context_id": "22d2a8a9-cc52-4c65-a392-52a625fb9a3e" + }, + "meta": { + "progressToken": 1 + }, + "client": { + "name": "grok-shell-peertable_probe", + "version": "1.0.41" + } + }, + { + "at": "2026-09-28T13:49:35.171Z", + "kind": "mcp_result", + "name": "parent_join", + "result": { + "code": "PROBE_BOUND", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244" + } + }, + { + "at": "2026-09-28T13:49:35.301Z", + "kind": "hook", + "event": { + "hook_event_name": "PostToolUse", + "hookEventName": "post_tool_use", + "sessionId": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "session_id": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "tool_name": "peertable_probe__parent_join", + "toolName": "peertable_probe__parent_join", + "tool_use_id": "call-5ba882b3-83e8-4ea0-bab4-59ffd1bbc09d-1", + "toolUseId": "call-5ba882b3-83e8-4ea0-bab4-59ffd1bbc09d-1", + "tool_input": { + "tool_name": "peertable_probe__parent_join", + "tool_input": { + "label": "同じ入力", + "hook_context_id": "22d2a8a9-cc52-4c65-a392-52a625fb9a3e" + } + }, + "toolInput": { + "tool_name": "peertable_probe__parent_join", + "tool_input": { + "label": "同じ入力", + "hook_context_id": "22d2a8a9-cc52-4c65-a392-52a625fb9a3e" + } + }, + "toolResult": { + "type": "MCP", + "tool_name": "parent_join", + "server_name": "peertable_probe", + "output": { + "OkayOutput": "{\"code\":\"PROBE_BOUND\",\"endpoint_id\":\"0da76a41-4c49-4f5c-96c2-5057a1365244\"}" + } + } + } + }, + { + "at": "2026-09-28T13:49:38.544Z", + "kind": "hook", + "event": { + "hook_event_name": "PreToolUse", + "hookEventName": "pre_tool_use", + "sessionId": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "session_id": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "tool_name": "peertable_probe__parent_read", + "toolName": "peertable_probe__parent_read", + "tool_use_id": "call-235e22cf-a3b5-4e7c-bebe-c2c4ea2fa4ed-2", + "toolUseId": "call-235e22cf-a3b5-4e7c-bebe-c2c4ea2fa4ed-2", + "tool_input": { + "tool_name": "peertable_probe__parent_read", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244" + } + }, + "toolInput": { + "tool_name": "peertable_probe__parent_read", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244" + } + } + } + }, + { + "at": "2026-09-28T13:49:38.547Z", + "kind": "rewrite", + "id": "f29a9076-a75c-4033-9e2a-aa25a10eb431", + "conversation": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "use": "call-235e22cf-a3b5-4e7c-bebe-c2c4ea2fa4ed-2", + "name": "peertable_probe__parent_read", + "input": { + "tool_name": "peertable_probe__parent_read", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244" + } + }, + "updated": { + "tool_name": "peertable_probe__parent_read", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244", + "hook_context_id": "f29a9076-a75c-4033-9e2a-aa25a10eb431" + } + } + }, + { + "at": "2026-09-28T13:49:38.661Z", + "kind": "hook", + "event": { + "hook_event_name": "PreToolUse", + "hookEventName": "pre_tool_use", + "sessionId": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "session_id": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "tool_name": "peertable_probe__parent_read", + "toolName": "peertable_probe__parent_read", + "tool_use_id": "call-235e22cf-a3b5-4e7c-bebe-c2c4ea2fa4ed-3", + "toolUseId": "call-235e22cf-a3b5-4e7c-bebe-c2c4ea2fa4ed-3", + "tool_input": { + "tool_name": "peertable_probe__parent_read", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244" + } + }, + "toolInput": { + "tool_name": "peertable_probe__parent_read", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244" + } + } + } + }, + { + "at": "2026-09-28T13:49:38.664Z", + "kind": "rewrite", + "id": "f2825df5-a04e-4074-a74f-03fc3ba80d22", + "conversation": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "use": "call-235e22cf-a3b5-4e7c-bebe-c2c4ea2fa4ed-3", + "name": "peertable_probe__parent_read", + "input": { + "tool_name": "peertable_probe__parent_read", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244" + } + }, + "updated": { + "tool_name": "peertable_probe__parent_read", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244", + "hook_context_id": "f2825df5-a04e-4074-a74f-03fc3ba80d22" + } + } + }, + { + "at": "2026-09-28T13:49:38.670Z", + "kind": "mcp_call", + "name": "parent_read", + "args": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244", + "hook_context_id": "f29a9076-a75c-4033-9e2a-aa25a10eb431" + }, + "meta": { + "progressToken": 2 + }, + "client": { + "name": "grok-shell-peertable_probe", + "version": "1.0.41" + } + }, + { + "at": "2026-09-28T13:49:38.670Z", + "kind": "mcp_call", + "name": "parent_read", + "args": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244", + "hook_context_id": "f2825df5-a04e-4074-a74f-03fc3ba80d22" + }, + "meta": { + "progressToken": 3 + }, + "client": { + "name": "grok-shell-peertable_probe", + "version": "1.0.41" + } + }, + { + "at": "2026-09-28T13:49:38.674Z", + "kind": "mcp_result", + "name": "parent_read", + "result": { + "code": "PROBE_SAME_CALLER", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244" + } + }, + { + "at": "2026-09-28T13:49:38.674Z", + "kind": "mcp_result", + "name": "parent_read", + "result": { + "code": "PROBE_SAME_CALLER", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244" + } + }, + { + "at": "2026-09-28T13:49:38.801Z", + "kind": "hook", + "event": { + "hook_event_name": "PostToolUse", + "hookEventName": "post_tool_use", + "sessionId": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "session_id": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "tool_name": "peertable_probe__parent_read", + "toolName": "peertable_probe__parent_read", + "tool_use_id": "call-235e22cf-a3b5-4e7c-bebe-c2c4ea2fa4ed-2", + "toolUseId": "call-235e22cf-a3b5-4e7c-bebe-c2c4ea2fa4ed-2", + "tool_input": { + "tool_name": "peertable_probe__parent_read", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244", + "hook_context_id": "f29a9076-a75c-4033-9e2a-aa25a10eb431" + } + }, + "toolInput": { + "tool_name": "peertable_probe__parent_read", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244", + "hook_context_id": "f29a9076-a75c-4033-9e2a-aa25a10eb431" + } + }, + "toolResult": { + "type": "MCP", + "tool_name": "parent_read", + "server_name": "peertable_probe", + "output": { + "OkayOutput": "{\"code\":\"PROBE_SAME_CALLER\",\"endpoint_id\":\"0da76a41-4c49-4f5c-96c2-5057a1365244\"}" + } + } + } + }, + { + "at": "2026-09-28T13:49:38.919Z", + "kind": "hook", + "event": { + "hook_event_name": "PostToolUse", + "hookEventName": "post_tool_use", + "sessionId": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "session_id": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "tool_name": "peertable_probe__parent_read", + "toolName": "peertable_probe__parent_read", + "tool_use_id": "call-235e22cf-a3b5-4e7c-bebe-c2c4ea2fa4ed-3", + "toolUseId": "call-235e22cf-a3b5-4e7c-bebe-c2c4ea2fa4ed-3", + "tool_input": { + "tool_name": "peertable_probe__parent_read", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244", + "hook_context_id": "f2825df5-a04e-4074-a74f-03fc3ba80d22" + } + }, + "toolInput": { + "tool_name": "peertable_probe__parent_read", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244", + "hook_context_id": "f2825df5-a04e-4074-a74f-03fc3ba80d22" + } + }, + "toolResult": { + "type": "MCP", + "tool_name": "parent_read", + "server_name": "peertable_probe", + "output": { + "OkayOutput": "{\"code\":\"PROBE_SAME_CALLER\",\"endpoint_id\":\"0da76a41-4c49-4f5c-96c2-5057a1365244\"}" + } + } + } + }, + { + "at": "2026-09-28T13:49:48.891Z", + "kind": "hook", + "event": { + "hook_event_name": "PreToolUse", + "hookEventName": "pre_tool_use", + "sessionId": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "session_id": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "tool_name": "peertable_probe__parent_leave", + "toolName": "peertable_probe__parent_leave", + "tool_use_id": "call-ffea6608-505d-4744-bdaa-e8fa9912d16e-4", + "toolUseId": "call-ffea6608-505d-4744-bdaa-e8fa9912d16e-4", + "tool_input": { + "tool_name": "peertable_probe__parent_leave", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244" + } + }, + "toolInput": { + "tool_name": "peertable_probe__parent_leave", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244" + } + } + } + }, + { + "at": "2026-09-28T13:49:48.896Z", + "kind": "rewrite", + "id": "3b1e1b8f-6959-4e29-8763-156cde5bbb7c", + "conversation": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "use": "call-ffea6608-505d-4744-bdaa-e8fa9912d16e-4", + "name": "peertable_probe__parent_leave", + "input": { + "tool_name": "peertable_probe__parent_leave", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244" + } + }, + "updated": { + "tool_name": "peertable_probe__parent_leave", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244", + "hook_context_id": "3b1e1b8f-6959-4e29-8763-156cde5bbb7c" + } + } + }, + { + "at": "2026-09-28T13:49:48.900Z", + "kind": "mcp_call", + "name": "parent_leave", + "args": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244", + "hook_context_id": "3b1e1b8f-6959-4e29-8763-156cde5bbb7c" + }, + "meta": { + "progressToken": 4 + }, + "client": { + "name": "grok-shell-peertable_probe", + "version": "1.0.41" + } + }, + { + "at": "2026-09-28T13:49:48.907Z", + "kind": "mcp_result", + "name": "parent_leave", + "result": { + "code": "PROBE_SAME_CALLER", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244" + } + }, + { + "at": "2026-09-28T13:49:49.220Z", + "kind": "hook", + "event": { + "hook_event_name": "PostToolUse", + "hookEventName": "post_tool_use", + "sessionId": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "session_id": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "tool_name": "peertable_probe__parent_leave", + "toolName": "peertable_probe__parent_leave", + "tool_use_id": "call-ffea6608-505d-4744-bdaa-e8fa9912d16e-4", + "toolUseId": "call-ffea6608-505d-4744-bdaa-e8fa9912d16e-4", + "tool_input": { + "tool_name": "peertable_probe__parent_leave", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244", + "hook_context_id": "3b1e1b8f-6959-4e29-8763-156cde5bbb7c" + } + }, + "toolInput": { + "tool_name": "peertable_probe__parent_leave", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244", + "hook_context_id": "3b1e1b8f-6959-4e29-8763-156cde5bbb7c" + } + }, + "toolResult": { + "type": "MCP", + "tool_name": "parent_leave", + "server_name": "peertable_probe", + "output": { + "OkayOutput": "{\"code\":\"PROBE_SAME_CALLER\",\"endpoint_id\":\"0da76a41-4c49-4f5c-96c2-5057a1365244\"}" + } + } + } + }, + { + "at": "2026-09-28T13:49:52.463Z", + "kind": "hook", + "event": { + "hook_event_name": "Stop", + "hookEventName": "stop", + "sessionId": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "session_id": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "lastAssistantMessage": "`PROBE_BOUND` \n`PROBE_SAME_CALLER` \n`PROBE_SAME_CALLER` \n`PROBE_SAME_CALLER`", + "reason": "end_turn", + "backgroundTasks": [] + } + }, + { + "at": "2026-09-28T13:49:52.772Z", + "kind": "hook", + "event": { + "hook_event_name": "SessionEnd", + "hookEventName": "session_end", + "sessionId": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "session_id": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "reason": "shutdown" + } + }, + { + "at": "2026-09-28T13:49:53.079Z", + "kind": "hook", + "event": { + "hook_event_name": "Stop", + "hookEventName": "stop", + "sessionId": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "session_id": "01a0e847-0d43-7672-bdfd-137b6a8cced1", + "reason": "shutdown" + } + }, + { + "at": "2026-09-28T13:50:48.766Z", + "kind": "rewrite", + "id": "d924b414-31ca-4547-a630-83d498ce2591", + "conversation": "01a0e848-2ced-78a3-a6cc-64c20800ba9d", + "use": "call-7cc5915f-5d6e-4345-9d13-c62fd4d538e2-1", + "name": "peertable_probe.parent_read", + "input": { + "tool_name": "peertable_probe.parent_read", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244" + } + }, + "updated": { + "tool_name": "peertable_probe.parent_read", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244", + "hook_context_id": "d924b414-31ca-4547-a630-83d498ce2591" + } + } + }, + { + "at": "2026-09-28T13:53:10.710Z", + "kind": "hook", + "event": { + "hook_event_name": "PreToolUse", + "hookEventName": "pre_tool_use", + "sessionId": "01a0e84a-238b-76e1-b7cd-c14069a20945", + "session_id": "01a0e84a-238b-76e1-b7cd-c14069a20945", + "tool_name": "run_terminal_command", + "toolName": "run_terminal_command", + "tool_use_id": "call-a9b97540-a983-47d8-bc55-9ae6b622e4c6-0", + "toolUseId": "call-a9b97540-a983-47d8-bc55-9ae6b622e4c6-0", + "tool_input": { + "command": "/opt/homebrew/bin/node '/Users/kite/.codex/worktrees/aiterm-unified-delivery/peertable/experiments/parent-background-wait-probe.mjs' wait '/var/folders/v4/ntdd_q2d10q962kq3cfx8lr00000gn/T/peertable grok 本人性 SE4ZDk'", + "description": "Launch parent background wait probe", + "background": true, + "timeout": 0 + }, + "toolInput": { + "command": "/opt/homebrew/bin/node '/Users/kite/.codex/worktrees/aiterm-unified-delivery/peertable/experiments/parent-background-wait-probe.mjs' wait '/var/folders/v4/ntdd_q2d10q962kq3cfx8lr00000gn/T/peertable grok 本人性 SE4ZDk'", + "description": "Launch parent background wait probe", + "background": true, + "timeout": 0 + } + } + }, + { + "at": "2026-09-28T13:53:10.851Z", + "kind": "hook", + "event": { + "hook_event_name": "PostToolUse", + "hookEventName": "post_tool_use", + "sessionId": "01a0e84a-238b-76e1-b7cd-c14069a20945", + "session_id": "01a0e84a-238b-76e1-b7cd-c14069a20945", + "tool_name": "run_terminal_command", + "toolName": "run_terminal_command", + "tool_use_id": "call-a9b97540-a983-47d8-bc55-9ae6b622e4c6-0", + "toolUseId": "call-a9b97540-a983-47d8-bc55-9ae6b622e4c6-0", + "tool_input": { + "command": "/opt/homebrew/bin/node '/Users/kite/.codex/worktrees/aiterm-unified-delivery/peertable/experiments/parent-background-wait-probe.mjs' wait '/var/folders/v4/ntdd_q2d10q962kq3cfx8lr00000gn/T/peertable grok 本人性 SE4ZDk'", + "description": "Launch parent background wait probe", + "background": true, + "timeout": 0 + }, + "toolInput": { + "command": "/opt/homebrew/bin/node '/Users/kite/.codex/worktrees/aiterm-unified-delivery/peertable/experiments/parent-background-wait-probe.mjs' wait '/var/folders/v4/ntdd_q2d10q962kq3cfx8lr00000gn/T/peertable grok 本人性 SE4ZDk'", + "description": "Launch parent background wait probe", + "background": true, + "timeout": 0 + }, + "toolResult": { + "type": "BackgroundTaskStarted", + "task_id": "01a0e84a-7c4f-7830-bb79-ecd48e0706d2", + "task_type": "bash", + "output_file": "/Users/kite/.grok/sessions/%2Fprivate%2Fvar%2Ffolders%2Fv4%2Fntdd_q2d10q962kq3cfx8lr00000gn%2FT%2Fpeertable%20grok%20%E6%9C%AC%E4%BA%BA%E6%80%A7%20SE4ZDk/01a0e84a-238b-76e1-b7cd-c14069a20945/terminal/call-a9b97540-a983-47d8-bc55-9ae6b622e4c6-0.log", + "status": "running", + "command": "/opt/homebrew/bin/node '/Users/kite/.codex/worktrees/aiterm-unified-delivery/peertable/experiments/parent-background-wait-probe.mjs' wait '/var/folders/v4/ntdd_q2d10q962kq3cfx8lr00000gn/T/peertable grok 本人性 SE4ZDk'", + "summary": "Background task 01a0e84a-7c4f-7830-bb79-ecd48e0706d2 started", + "retrieval_hint": "Use get_command_or_subagent_output with task_ids=[\"01a0e84a-7c4f-7830-bb79-ecd48e0706d2\"] when you need the output.", + "pid": 52296 + } + } + }, + { + "at": "2026-09-28T13:53:19.103Z", + "kind": "hook", + "event": { + "hook_event_name": "Stop", + "hookEventName": "stop", + "sessionId": "01a0e84a-238b-76e1-b7cd-c14069a20945", + "session_id": "01a0e84a-238b-76e1-b7cd-c14069a20945", + "lastAssistantMessage": "背景受信登録済み。native 背景 task `01a0e84a-7c4f-7830-bb79-ecd48e0706d2` を登録済みで、完了通知待ちで idle に入ります。", + "reason": "end_turn", + "backgroundTasks": [ + { + "id": "01a0e84a-7c4f-7830-bb79-ecd48e0706d2", + "type": "shell", + "status": "running", + "command": "/opt/homebrew/bin/node '/Users/kite/.codex/worktrees/aiterm-unified-delivery/peertable/experiments/parent-background-wait-probe.mjs' wait '/var/folders/v4/ntdd_q2d10q962kq3cfx8lr00000gn/T/peertable grok 本人性 SE4ZDk'" + } + ] + } + }, + { + "at": "2026-09-28T13:53:55.474Z", + "kind": "hook", + "event": { + "hook_event_name": "PreToolUse", + "hookEventName": "pre_tool_use", + "sessionId": "01a0e84a-238b-76e1-b7cd-c14069a20945", + "session_id": "01a0e84a-238b-76e1-b7cd-c14069a20945", + "tool_name": "get_command_or_subagent_output", + "toolName": "get_command_or_subagent_output", + "tool_use_id": "call-9a5bc4ef-ba86-4ba8-8e87-c728de6da914-1", + "toolUseId": "call-9a5bc4ef-ba86-4ba8-8e87-c728de6da914-1", + "tool_input": { + "task_ids": [ + "01a0e84a-7c4f-7830-bb79-ecd48e0706d2" + ] + }, + "toolInput": { + "task_ids": [ + "01a0e84a-7c4f-7830-bb79-ecd48e0706d2" + ] + } + } + }, + { + "at": "2026-09-28T13:53:55.601Z", + "kind": "hook", + "event": { + "hook_event_name": "PostToolUse", + "hookEventName": "post_tool_use", + "sessionId": "01a0e84a-238b-76e1-b7cd-c14069a20945", + "session_id": "01a0e84a-238b-76e1-b7cd-c14069a20945", + "tool_name": "get_command_or_subagent_output", + "toolName": "get_command_or_subagent_output", + "tool_use_id": "call-9a5bc4ef-ba86-4ba8-8e87-c728de6da914-1", + "toolUseId": "call-9a5bc4ef-ba86-4ba8-8e87-c728de6da914-1", + "tool_input": { + "task_ids": [ + "01a0e84a-7c4f-7830-bb79-ecd48e0706d2" + ] + }, + "toolInput": { + "task_ids": [ + "01a0e84a-7c4f-7830-bb79-ecd48e0706d2" + ] + }, + "toolResult": { + "type": "TaskOutput", + "Result": { + "task_id": "01a0e84a-7c4f-7830-bb79-ecd48e0706d2", + "command": "/opt/homebrew/bin/node '/Users/kite/.codex/worktrees/aiterm-unified-delivery/peertable/experiments/parent-background-wait-probe.mjs' wait '/var/folders/v4/ntdd_q2d10q962kq3cfx8lr00000gn/T/peertable grok 本人性 SE4ZDk'", + "status": "completed", + "exit_code": 0, + "started": "2026-09-28T13:53:10Z", + "ended": "2026-09-28T13:53:50Z", + "duration_secs": 40.097074, + "output": "{\"outcome\":\"message\",\"room\":\"配送試験\",\"from\":\"probe\",\"to\":\"bell\",\"seq\":\"1\",\"body\":\"符号 GROK_BACKGROUND_ONE_日本語\",\"waiter\":\"089ed1f5-62ab-4e4c-ab2c-29d139b0b88c\"}\n", + "output_file": "/Users/kite/.grok/sessions/%2Fprivate%2Fvar%2Ffolders%2Fv4%2Fntdd_q2d10q962kq3cfx8lr00000gn%2FT%2Fpeertable%20grok%20%E6%9C%AC%E4%BA%BA%E6%80%A7%20SE4ZDk/01a0e84a-238b-76e1-b7cd-c14069a20945/terminal/call-a9b97540-a983-47d8-bc55-9ae6b622e4c6-0.log", + "truncated": false, + "truncation_hint": "[truncated - use read_file on output_file for full content]", + "raw_output_bytes": 175 + } + } + } + }, + { + "at": "2026-09-28T13:54:00.131Z", + "kind": "hook", + "event": { + "hook_event_name": "PreToolUse", + "hookEventName": "pre_tool_use", + "sessionId": "01a0e84a-238b-76e1-b7cd-c14069a20945", + "session_id": "01a0e84a-238b-76e1-b7cd-c14069a20945", + "tool_name": "run_terminal_command", + "toolName": "run_terminal_command", + "tool_use_id": "call-cff3f8f5-dc3c-4b65-9886-2f5c2a3d2cc6-2", + "toolUseId": "call-cff3f8f5-dc3c-4b65-9886-2f5c2a3d2cc6-2", + "tool_input": { + "command": "/opt/homebrew/bin/node '/Users/kite/.codex/worktrees/aiterm-unified-delivery/peertable/experiments/parent-background-wait-probe.mjs' wait '/var/folders/v4/ntdd_q2d10q962kq3cfx8lr00000gn/T/peertable grok 本人性 SE4ZDk'", + "description": "Reregister parent background wait probe", + "background": true, + "timeout": 0 + }, + "toolInput": { + "command": "/opt/homebrew/bin/node '/Users/kite/.codex/worktrees/aiterm-unified-delivery/peertable/experiments/parent-background-wait-probe.mjs' wait '/var/folders/v4/ntdd_q2d10q962kq3cfx8lr00000gn/T/peertable grok 本人性 SE4ZDk'", + "description": "Reregister parent background wait probe", + "background": true, + "timeout": 0 + } + } + }, + { + "at": "2026-09-28T13:54:00.270Z", + "kind": "hook", + "event": { + "hook_event_name": "PostToolUse", + "hookEventName": "post_tool_use", + "sessionId": "01a0e84a-238b-76e1-b7cd-c14069a20945", + "session_id": "01a0e84a-238b-76e1-b7cd-c14069a20945", + "tool_name": "run_terminal_command", + "toolName": "run_terminal_command", + "tool_use_id": "call-cff3f8f5-dc3c-4b65-9886-2f5c2a3d2cc6-2", + "toolUseId": "call-cff3f8f5-dc3c-4b65-9886-2f5c2a3d2cc6-2", + "tool_input": { + "command": "/opt/homebrew/bin/node '/Users/kite/.codex/worktrees/aiterm-unified-delivery/peertable/experiments/parent-background-wait-probe.mjs' wait '/var/folders/v4/ntdd_q2d10q962kq3cfx8lr00000gn/T/peertable grok 本人性 SE4ZDk'", + "description": "Reregister parent background wait probe", + "background": true, + "timeout": 0 + }, + "toolInput": { + "command": "/opt/homebrew/bin/node '/Users/kite/.codex/worktrees/aiterm-unified-delivery/peertable/experiments/parent-background-wait-probe.mjs' wait '/var/folders/v4/ntdd_q2d10q962kq3cfx8lr00000gn/T/peertable grok 本人性 SE4ZDk'", + "description": "Reregister parent background wait probe", + "background": true, + "timeout": 0 + }, + "toolResult": { + "type": "BackgroundTaskStarted", + "task_id": "01a0e84b-3d4e-7bd1-9170-a47787b85491", + "task_type": "bash", + "output_file": "/Users/kite/.grok/sessions/%2Fprivate%2Fvar%2Ffolders%2Fv4%2Fntdd_q2d10q962kq3cfx8lr00000gn%2FT%2Fpeertable%20grok%20%E6%9C%AC%E4%BA%BA%E6%80%A7%20SE4ZDk/01a0e84a-238b-76e1-b7cd-c14069a20945/terminal/call-cff3f8f5-dc3c-4b65-9886-2f5c2a3d2cc6-2.log", + "status": "running", + "command": "/opt/homebrew/bin/node '/Users/kite/.codex/worktrees/aiterm-unified-delivery/peertable/experiments/parent-background-wait-probe.mjs' wait '/var/folders/v4/ntdd_q2d10q962kq3cfx8lr00000gn/T/peertable grok 本人性 SE4ZDk'", + "summary": "Background task 01a0e84b-3d4e-7bd1-9170-a47787b85491 started", + "retrieval_hint": "Use get_command_or_subagent_output with task_ids=[\"01a0e84b-3d4e-7bd1-9170-a47787b85491\"] when you need the output.", + "pid": 53960 + } + } + }, + { + "at": "2026-09-28T13:54:02.680Z", + "kind": "hook", + "event": { + "hook_event_name": "Stop", + "hookEventName": "stop", + "sessionId": "01a0e84a-238b-76e1-b7cd-c14069a20945", + "session_id": "01a0e84a-238b-76e1-b7cd-c14069a20945", + "lastAssistantMessage": "背景受信登録済み。native 背景 task `01a0e84b-3d4e-7bd1-9170-a47787b85491` を再登録済みで、完了通知待ちで idle に入ります。", + "reason": "end_turn", + "backgroundTasks": [ + { + "id": "01a0e84b-3d4e-7bd1-9170-a47787b85491", + "type": "shell", + "status": "running", + "command": "/opt/homebrew/bin/node '/Users/kite/.codex/worktrees/aiterm-unified-delivery/peertable/experiments/parent-background-wait-probe.mjs' wait '/var/folders/v4/ntdd_q2d10q962kq3cfx8lr00000gn/T/peertable grok 本人性 SE4ZDk'" + } + ] + } + }, + { + "at": "2026-09-28T13:54:28.903Z", + "kind": "hook", + "event": { + "hook_event_name": "PreToolUse", + "hookEventName": "pre_tool_use", + "sessionId": "01a0e84a-238b-76e1-b7cd-c14069a20945", + "session_id": "01a0e84a-238b-76e1-b7cd-c14069a20945", + "tool_name": "get_command_or_subagent_output", + "toolName": "get_command_or_subagent_output", + "tool_use_id": "call-9ea44ece-8cf1-486f-8a20-6ea2af59e796-3", + "toolUseId": "call-9ea44ece-8cf1-486f-8a20-6ea2af59e796-3", + "tool_input": { + "task_ids": [ + "01a0e84b-3d4e-7bd1-9170-a47787b85491" + ] + }, + "toolInput": { + "task_ids": [ + "01a0e84b-3d4e-7bd1-9170-a47787b85491" + ] + } + } + }, + { + "at": "2026-09-28T13:54:29.025Z", + "kind": "hook", + "event": { + "hook_event_name": "PostToolUse", + "hookEventName": "post_tool_use", + "sessionId": "01a0e84a-238b-76e1-b7cd-c14069a20945", + "session_id": "01a0e84a-238b-76e1-b7cd-c14069a20945", + "tool_name": "get_command_or_subagent_output", + "toolName": "get_command_or_subagent_output", + "tool_use_id": "call-9ea44ece-8cf1-486f-8a20-6ea2af59e796-3", + "toolUseId": "call-9ea44ece-8cf1-486f-8a20-6ea2af59e796-3", + "tool_input": { + "task_ids": [ + "01a0e84b-3d4e-7bd1-9170-a47787b85491" + ] + }, + "toolInput": { + "task_ids": [ + "01a0e84b-3d4e-7bd1-9170-a47787b85491" + ] + }, + "toolResult": { + "type": "TaskOutput", + "Result": { + "task_id": "01a0e84b-3d4e-7bd1-9170-a47787b85491", + "command": "/opt/homebrew/bin/node '/Users/kite/.codex/worktrees/aiterm-unified-delivery/peertable/experiments/parent-background-wait-probe.mjs' wait '/var/folders/v4/ntdd_q2d10q962kq3cfx8lr00000gn/T/peertable grok 本人性 SE4ZDk'", + "status": "completed", + "exit_code": 0, + "started": "2026-09-28T13:54:00Z", + "ended": "2026-09-28T13:54:24Z", + "duration_secs": 24.631875, + "output": "{\"outcome\":\"message\",\"room\":\"配送試験\",\"from\":\"probe\",\"to\":\"bell\",\"seq\":\"2\",\"body\":\"符号 GROK_BACKGROUND_TWO_日本語\",\"waiter\":\"9d4b43dd-1c0d-4b5a-9ee7-d67d0d0b1cc5\"}\n", + "output_file": "/Users/kite/.grok/sessions/%2Fprivate%2Fvar%2Ffolders%2Fv4%2Fntdd_q2d10q962kq3cfx8lr00000gn%2FT%2Fpeertable%20grok%20%E6%9C%AC%E4%BA%BA%E6%80%A7%20SE4ZDk/01a0e84a-238b-76e1-b7cd-c14069a20945/terminal/call-cff3f8f5-dc3c-4b65-9886-2f5c2a3d2cc6-2.log", + "truncated": false, + "truncation_hint": "[truncated - use read_file on output_file for full content]", + "raw_output_bytes": 175 + } + } + } + }, + { + "at": "2026-09-28T13:54:31.173Z", + "kind": "hook", + "event": { + "hook_event_name": "Stop", + "hookEventName": "stop", + "sessionId": "01a0e84a-238b-76e1-b7cd-c14069a20945", + "session_id": "01a0e84a-238b-76e1-b7cd-c14069a20945", + "lastAssistantMessage": "2件目: seq=`2`、本文符号=`GROK_BACKGROUND_TWO_日本語`。再登録せず、ここで終了します。", + "reason": "end_turn", + "backgroundTasks": [] + } + }, + { + "at": "2026-09-28T13:55:28.603Z", + "kind": "hook", + "event": { + "hook_event_name": "PreToolUse", + "hookEventName": "pre_tool_use", + "sessionId": "01a0e84c-537e-7232-9379-8209fae73e1f", + "session_id": "01a0e84c-537e-7232-9379-8209fae73e1f", + "tool_name": "peertable_probe__parent_read", + "toolName": "peertable_probe__parent_read", + "tool_use_id": "call-58d7bf53-d86d-4baa-b9af-bbb475aff132-0", + "toolUseId": "call-58d7bf53-d86d-4baa-b9af-bbb475aff132-0", + "tool_input": { + "tool_name": "peertable_probe__parent_read", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244" + } + }, + "toolInput": { + "tool_name": "peertable_probe__parent_read", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244" + } + } + } + }, + { + "at": "2026-09-28T13:55:28.605Z", + "kind": "rewrite", + "id": "656885ab-7134-4ecc-aa86-d6df0105bf6e", + "conversation": "01a0e84c-537e-7232-9379-8209fae73e1f", + "use": "call-58d7bf53-d86d-4baa-b9af-bbb475aff132-0", + "name": "peertable_probe__parent_read", + "input": { + "tool_name": "peertable_probe__parent_read", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244" + } + }, + "updated": { + "tool_name": "peertable_probe__parent_read", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244", + "hook_context_id": "656885ab-7134-4ecc-aa86-d6df0105bf6e" + } + } + }, + { + "at": "2026-09-28T13:55:28.618Z", + "kind": "mcp_call", + "name": "parent_read", + "args": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244", + "hook_context_id": "656885ab-7134-4ecc-aa86-d6df0105bf6e" + }, + "meta": { + "progressToken": 1 + }, + "client": { + "name": "grok-shell-peertable_probe", + "version": "1.0.41" + } + }, + { + "at": "2026-09-28T13:55:28.624Z", + "kind": "mcp_result", + "name": "parent_read", + "result": { + "code": "PARENT_CALLER_MISMATCH", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244" + } + }, + { + "at": "2026-09-28T13:55:28.745Z", + "kind": "hook", + "event": { + "hook_event_name": "PostToolUseFailure", + "hookEventName": "post_tool_use_failure", + "sessionId": "01a0e84c-537e-7232-9379-8209fae73e1f", + "session_id": "01a0e84c-537e-7232-9379-8209fae73e1f", + "tool_name": "peertable_probe__parent_read", + "toolName": "peertable_probe__parent_read", + "tool_use_id": "call-58d7bf53-d86d-4baa-b9af-bbb475aff132-0", + "toolUseId": "call-58d7bf53-d86d-4baa-b9af-bbb475aff132-0", + "tool_input": { + "tool_name": "peertable_probe__parent_read", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244", + "hook_context_id": "656885ab-7134-4ecc-aa86-d6df0105bf6e" + } + }, + "toolInput": { + "tool_name": "peertable_probe__parent_read", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244", + "hook_context_id": "656885ab-7134-4ecc-aa86-d6df0105bf6e" + } + } + } + }, + { + "at": "2026-09-28T13:55:31.507Z", + "kind": "hook", + "event": { + "hook_event_name": "PreToolUse", + "hookEventName": "pre_tool_use", + "sessionId": "01a0e84c-537e-7232-9379-8209fae73e1f", + "session_id": "01a0e84c-537e-7232-9379-8209fae73e1f", + "tool_name": "peertable_probe__parent_leave", + "toolName": "peertable_probe__parent_leave", + "tool_use_id": "call-953c263f-72bd-4016-94ae-3b23c4642714-1", + "toolUseId": "call-953c263f-72bd-4016-94ae-3b23c4642714-1", + "tool_input": { + "tool_name": "peertable_probe__parent_leave", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244" + } + }, + "toolInput": { + "tool_name": "peertable_probe__parent_leave", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244" + } + } + } + }, + { + "at": "2026-09-28T13:55:31.510Z", + "kind": "rewrite", + "id": "ff48a609-9986-4e0c-8822-b3f17431038d", + "conversation": "01a0e84c-537e-7232-9379-8209fae73e1f", + "use": "call-953c263f-72bd-4016-94ae-3b23c4642714-1", + "name": "peertable_probe__parent_leave", + "input": { + "tool_name": "peertable_probe__parent_leave", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244" + } + }, + "updated": { + "tool_name": "peertable_probe__parent_leave", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244", + "hook_context_id": "ff48a609-9986-4e0c-8822-b3f17431038d" + } + } + }, + { + "at": "2026-09-28T13:55:31.513Z", + "kind": "mcp_call", + "name": "parent_leave", + "args": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244", + "hook_context_id": "ff48a609-9986-4e0c-8822-b3f17431038d" + }, + "meta": { + "progressToken": 2 + }, + "client": { + "name": "grok-shell-peertable_probe", + "version": "1.0.41" + } + }, + { + "at": "2026-09-28T13:55:31.517Z", + "kind": "mcp_result", + "name": "parent_leave", + "result": { + "code": "PARENT_CALLER_MISMATCH", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244" + } + }, + { + "at": "2026-09-28T13:55:31.630Z", + "kind": "hook", + "event": { + "hook_event_name": "PostToolUseFailure", + "hookEventName": "post_tool_use_failure", + "sessionId": "01a0e84c-537e-7232-9379-8209fae73e1f", + "session_id": "01a0e84c-537e-7232-9379-8209fae73e1f", + "tool_name": "peertable_probe__parent_leave", + "toolName": "peertable_probe__parent_leave", + "tool_use_id": "call-953c263f-72bd-4016-94ae-3b23c4642714-1", + "toolUseId": "call-953c263f-72bd-4016-94ae-3b23c4642714-1", + "tool_input": { + "tool_name": "peertable_probe__parent_leave", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244", + "hook_context_id": "ff48a609-9986-4e0c-8822-b3f17431038d" + } + }, + "toolInput": { + "tool_name": "peertable_probe__parent_leave", + "tool_input": { + "label": "同じ入力", + "endpoint_id": "0da76a41-4c49-4f5c-96c2-5057a1365244", + "hook_context_id": "ff48a609-9986-4e0c-8822-b3f17431038d" + } + } + } + } + ], + "background_wait_events": [ + { + "at": "2026-09-28T13:53:10.834Z", + "kind": "armed", + "waiter": "089ed1f5-62ab-4e4c-ab2c-29d139b0b88c", + "pid": 52300 + }, + { + "at": "2026-09-28T13:53:50.819Z", + "kind": "injected", + "id": "1", + "body": "符号 GROK_BACKGROUND_ONE_日本語" + }, + { + "at": "2026-09-28T13:53:50.829Z", + "kind": "output", + "result": { + "outcome": "message", + "room": "配送試験", + "from": "probe", + "to": "bell", + "seq": "1", + "body": "符号 GROK_BACKGROUND_ONE_日本語", + "waiter": "089ed1f5-62ab-4e4c-ab2c-29d139b0b88c" + } + }, + { + "at": "2026-09-28T13:54:00.236Z", + "kind": "armed", + "waiter": "9d4b43dd-1c0d-4b5a-9ee7-d67d0d0b1cc5", + "pid": 53964 + }, + { + "at": "2026-09-28T13:54:24.672Z", + "kind": "injected", + "id": "2", + "body": "符号 GROK_BACKGROUND_TWO_日本語" + }, + { + "at": "2026-09-28T13:54:24.770Z", + "kind": "output", + "result": { + "outcome": "message", + "room": "配送試験", + "from": "probe", + "to": "bell", + "seq": "2", + "body": "符号 GROK_BACKGROUND_TWO_日本語", + "waiter": "9d4b43dd-1c0d-4b5a-9ee7-d67d0d0b1cc5" + } + } + ], + "assistant_observed": [ + "2件目: seq=2、本文符号=GROK_BACKGROUND_TWO_日本語。再登録せず、ここで終了します。" + ], + "same_caller": true, + "other_caller_rejected": true, + "native_parallel_reads": true, + "idle_background_two_messages": true, + "product_spool": false + } + } +} diff --git a/rag/parent-delivery/phase1-macos-caller-background.md b/rag/parent-delivery/phase1-macos-caller-background.md new file mode 100644 index 00000000..fc743a66 --- /dev/null +++ b/rag/parent-delivery/phase1-macos-caller-background.md @@ -0,0 +1,51 @@ +# 事前hookと背景完了の成立probe + +取得日: 2026-09-28。確度: macOSの公式CLIと通常TUIで実測。製品版の受入と3 OS全行の合格は未実施。 + +## 確認できた範囲 + +`experiments/parent-caller-hook-probe.mjs`は使い捨てprojectに製品専用MCPとhookを置く。通常HOMEと認証を保持し、Aitermのmodule・内部stateは使わない。Grokのproject hookはgit rootと公式`/hooks-trust`による信頼登録を必要とした。fixtureのglobal hookは追加していない。 + +| 対象 | 実測 | +| --- | --- | +| Cursor CLI `2026.09.26-dd393fe` | 事前hookの相関をMCP引数へ追加。join、同じ入力のread 2件、leaveが同じ実会話で成立。別会話のread/leaveは両方`PARENT_CALLER_MISMATCH`。実CLIのread 2件は逐次実行であり、並行呼出し成立へ数えない | +| Grok Build `1.0.41 (4220f3b224a6)` | 相関追加後にjoin、同じ入力の並行read 2件、leaveが成立。別会話のread/leaveは両方`PARENT_CALLER_MISMATCH` | +| Cursor通常TUI、model Auto | 同じ会話`b66e8d43-7732-425a-b36d-6877cc7c5bfa`でnative背景完了からidle起床。親が2件目の背景taskを登録し、2件ともseqと日本語符号を回答。3件目の待機は起動していない | +| Grok通常TUI、model Grok 4.6 (high) | 同じ会話`01a0e84a-238b-76e1-b7cd-c14069a20945`でnative背景完了からidle起床。完了済みtaskを`get_command_or_subagent_output`でtimeoutなしに回収し、2件目の背景taskを登録。2件目のseqと日本語符号を回答。Stopの`backgroundTasks`は再武装後1件 | + +生の公式hookから配送に必要なfieldだけを投影した記録は[JSON証拠](phase1-macos-caller-background.json)に保存した。callerのPreToolUse、実MCP引数、結果、背景taskの起動・完了回収・Stopを照合できる。TUIの回答はAiterm公開`pty_read(screen:true)`でも照合した。利用者のemailと認証は記録へ含めない。 + +## 固定するpayload + +- Cursor CLIのMCP `clientInfo`は`{name: "Cursor", version: "1.0.0"}`。MCP要求の`_meta`に会話IDはなかった。 +- Cursor CLIの`preToolUse.tool_name`は`MCP:parent_join`等で、サーバー名を含まない。`tool_input`はMCPの引数object。`updated_input`へ元の全引数と`hook_context_id`を返すと、実MCPへ渡る。事後`afterMCPExecution`は`mcp_server_name`を含み、`tool_input`と`result_json`はJSON文字列である。製品はCLI client名をDesktopの`cursor-vscode`と同一視しない。 +- Cursorで対象外の`preToolUse`へ`{}`を返すとschema不一致によりtoolが拒否された。対象外も公式の`{permission: "allow"}`を返す。実MCP未到達をcaller拒否の成立証拠に数えない。 +- Grokの実hookは`hookEventName: "pre_tool_use"`と`hook_event_name: "PreToolUse"`を併記する。`sessionId`と`toolUseId`が実caller相関である。 +- GrokのMCP dispatchでは`toolName`が`peertable_probe__parent_join`、`toolInput`が`{tool_name, tool_input}`である。完全な外側objectを保持し、内側`tool_input`へ相関fieldを付加する。外側へのfield追加だけではMCPへ渡らず`PARENT_CALLER_UNBOUND`になった。MCP client名は`grok-shell-peertable_probe`、versionは`1.0.41`だった。 +- Cursorの背景toolは`Shell`。`tool_input`には`command`と`cwd`があり、起動結果はJSON文字列の`{shell_id, pid}`だった。本文回収は背景終了の通知後、公式`Read`でterminal出力を読む。元のShell入力には背景指定fieldがなかったため、完成済みnative tool入力の製品adapterは別途確定する。 +- Grokは`run_terminal_command`の`background: true, timeout: 0`で起動し、`toolResult.type: "BackgroundTaskStarted"`に`task_id`と`pid`が返る。完了回収の`task_ids`はこのIDを使い、`timeout_ms`を省略した。 + +caller相関fixtureはhookごとに発行IDを変え、tool名・元入力digest・実会話を保存してMCPで一回だけ消費する。別会話の判定は本文返却やleaveより先に行う。これは機構成立を調べるfixtureであり、製品版の世代・process本人性・失敗保存を実装済みとするものではない。 + +## 残件と非合格の観測 + +Codex CLI `0.155.1`の公式App Serverへ通常HOMEで接続し、`hooks/list`は応答した。一時git projectの`.codex/hooks.json`を置き、CLIの`projects..trust_level`を与えてもproject hookが一覧へ出なかった。canonical pathと空のproject configも試したが未成立であり、原因は未確定。次は公式APIによるproject信頼とhookの実効source・hashを確認する。ephemeral threadへの`thread/queue/add`は公式APIが`ephemeral thread does not support queued submissions`として拒否した。永続threadのqueue受付・同一turnへの同期hook・idle起床は未検証である。これらの試行にAiterm内部のreceiverや偽HOMEを代用していない。 + +pick-modelはCursor/Grok probe用の2呼出しとも`JEV_HTTP_520`で失敗した。モデル選定は未成立と報告し、公式CLIの既定モデルで機構を実測した。ハーネスの利用上限によるskip-successは行っていない。 + +このcheckpointの残りは、Codexのqueue+同期hook、Cursorの実並行caller試験、Cursor Desktop、Linux/Windowsのcallerと背景受信、作業中到着・競合・timeout/再武装、製品spoolとの統合である。Claude Stopとleaseの3 OS観測は親が担当する。製品のreceiver、connect、setup/resume/doctor/teardown、unknown receipt、長文継続回収、package更新、12組合せの受入は未実装・未実施。 + +## 再現入口 + +```sh +node experiments/parent-caller-hook-probe.mjs init cursor +node experiments/parent-caller-hook-probe.mjs init grok +node --test experiments/parent-caller-hook-probe.test.mjs +node --test experiments/parent-background-wait-probe.test.mjs +``` + +initが返すrootで公式CLIを起動する。Grokはrootをgit projectとして初期化した後、公式`/hooks-trust`で信頼する。通常HOMEを変えない。caller fixtureのpromptはrootの`prompt.txt`にある。背景fixtureは親自身のnative背景toolへ`node experiments/parent-background-wait-probe.mjs wait `を登録する。外部から`inject <本文>`を呼ぶと1件を出して終了する。親が次のnative背景toolを登録してから2件目を投入する。 + +背景fixtureのslotは起動重複と本文保持を観測するための小fixtureであり、製品runtimeのPID+開始identityの所有処理を代替しない。製品版では共通spool、process本人性、1slot、receiptを統合して再受入する。 + +一次資料: [Cursor hooks](https://cursor.com/docs/hooks)、Grok公式配布資料`~/.grok/docs/user-guide/10-hooks.md`・`20-background-tasks.md`、[Codex hooks](https://learn.chatgpt.com/docs/hooks)。 diff --git a/rag/parent-delivery/receivers.md b/rag/parent-delivery/receivers.md index ba12fdbd..4ca4d3c5 100644 --- a/rag/parent-delivery/receivers.md +++ b/rag/parent-delivery/receivers.md @@ -27,3 +27,5 @@ - [Cursorの公式導入資料](https://prod.cursor.com/docs/enterprise/deployment-patterns): CLIのWindows PowerShell installerが記載されている。古いWSL限定資料だけでWindows nativeの必須行を除外しない。 公式仕様の記述、Aitermでの試験、Peertableの新機構の成立は別の証拠である。Aitermの成功をPeertableの12組合せの成功へ置き換えない。 + +第1工程のmacOS実測は[caller相関と背景完了の記録](phase1-macos-caller-background.md)へ保存した。Cursor/Grokの事前hook入力書換え・別会話拒否・通常TUIの2件の背景受信を観測済み。Codex queue+同期hookと製品版の12組合せは未合格。 From 8d2c130866bdd6992a16bed2a35ecf6656e4b7e7 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:35:37 +0900 Subject: [PATCH 02/72] =?UTF-8?q?=E8=A6=AA=E3=81=AE=E5=85=AC=E5=BC=8F?= =?UTF-8?q?=E5=8F=97=E4=BF=A1=E5=8F=A3=E3=81=A8=E5=8E=9F=E6=96=87=E9=85=8D?= =?UTF-8?q?=E9=80=81=E3=82=92Peertable=E3=81=B8=E5=AE=9F=E8=A3=85=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 共通spool、親MCP三種、四receiver、所有hook/connectと3 OS adapterを追加する。 親を通常席から除外し、all全件とDM・複数宛を同じ会話へ配送する。 長文page所有、受付不変、receipt更新順、無投稿SSEの回収と診断を修理する。 HTTP UTF-8断片の原文破損をstream decoderで修理し、実HTTP再現をCIへ登録する。 focused試験、SDK stdio、receipt故障再現、構文・配布契約を確認。 525件の実機受入manifestは未達で公開gateは失敗を維持する。 --- README.ja.md | 2 +- README.md | 2 +- docs/current-design.md | 6 + docs/plan_parent-native-delivery.md | 6 +- experiments/delivery-receipt-repro.mjs | 44 ++- experiments/http-utf8-body-repro.mjs | 61 ++++ experiments/parent-role-repro.mjs | 63 ++-- .../windows-parent-member-json-repro.mjs | 3 +- package.json | 3 + rag/parent-delivery/product-acceptance.json | 4 + room/Dockerfile | 2 +- room/client.mjs | 7 +- room/parent-client.mjs | 84 +++++ room/parent-client.test.mjs | 64 ++++ room/parent-kind.mjs | 3 + room/server.mjs | 88 ++++-- scripts/ci-contract.test.mjs | 17 + scripts/parent-delivery-acceptance.mjs | 65 ++++ scripts/parent-delivery-acceptance.test.mjs | 22 ++ scripts/prepublish.mjs | 1 + scripts/run-product-ci.mjs | 16 +- skill/SKILL.md | 35 +-- skill/scripts/cli.mjs | 12 +- skill/scripts/doctor.mjs | 9 +- skill/scripts/ensure-all-bridges.mjs | 3 +- skill/scripts/ensure-project-runtime.mjs | 4 +- skill/scripts/parent-caller.mjs | 106 +++++++ skill/scripts/parent-connect.mjs | 202 ++++++++++++ skill/scripts/parent-delivery.mjs | 297 ++++++++++++++++++ skill/scripts/parent-delivery.test.mjs | 256 +++++++++++++++ skill/scripts/parent-hook.mjs | 59 ++++ skill/scripts/parent-join.sh | 115 +------ skill/scripts/parent-platform.mjs | 151 +++++++++ skill/scripts/parent-prepare.mjs | 22 ++ skill/scripts/parent-receive.mjs | 17 + skill/scripts/parent-receivers/background.mjs | 97 ++++++ skill/scripts/parent-receivers/claude.mjs | 70 +++++ skill/scripts/parent-receivers/codex.mjs | 150 +++++++++ skill/scripts/parent-receivers/cursor.mjs | 23 ++ skill/scripts/parent-receivers/grok.mjs | 2 + skill/scripts/parent-runtime.mjs | 166 ++++++++++ skill/scripts/parent-source-runtime.test.mjs | 76 +++++ skill/scripts/parent-watch.mjs | 107 +++++-- .../platform/windows/parent-member-json.mjs | 19 +- .../windows/register-parent-member.mjs | 13 +- skill/scripts/project-runtime.mjs | 14 +- skill/scripts/project-runtime.test.mjs | 5 +- skill/scripts/runtime-digest.mjs | 8 +- skill/scripts/seat-observer.mjs | 3 +- skill/scripts/seat-status-bridge.mjs | 21 +- skill/scripts/teardown.mjs | 24 +- skill/scripts/wakeup-bridge.mjs | 17 +- skill/scripts/wakeup-delivery.mjs | 3 +- skill/templates/parent.md | 47 +-- 54 files changed, 2393 insertions(+), 323 deletions(-) create mode 100644 experiments/http-utf8-body-repro.mjs create mode 100644 rag/parent-delivery/product-acceptance.json create mode 100644 room/parent-client.mjs create mode 100644 room/parent-client.test.mjs create mode 100644 room/parent-kind.mjs create mode 100644 scripts/parent-delivery-acceptance.mjs create mode 100644 scripts/parent-delivery-acceptance.test.mjs create mode 100644 skill/scripts/parent-caller.mjs create mode 100644 skill/scripts/parent-connect.mjs create mode 100644 skill/scripts/parent-delivery.mjs create mode 100644 skill/scripts/parent-delivery.test.mjs create mode 100644 skill/scripts/parent-hook.mjs create mode 100644 skill/scripts/parent-platform.mjs create mode 100644 skill/scripts/parent-prepare.mjs create mode 100644 skill/scripts/parent-receive.mjs create mode 100644 skill/scripts/parent-receivers/background.mjs create mode 100644 skill/scripts/parent-receivers/claude.mjs create mode 100644 skill/scripts/parent-receivers/codex.mjs create mode 100644 skill/scripts/parent-receivers/cursor.mjs create mode 100644 skill/scripts/parent-receivers/grok.mjs create mode 100644 skill/scripts/parent-runtime.mjs create mode 100644 skill/scripts/parent-source-runtime.test.mjs diff --git a/README.ja.md b/README.ja.md index e93f297f..c0077548 100644 --- a/README.ja.md +++ b/README.ja.md @@ -99,7 +99,7 @@ setupの再実行も既存projectではresumeへ進み、room・議題・生存 teardownの既定は解散。席と所有する足場を撤去し、roomと過去ログ、Lattice storeを残す。`--purge`はroomと新設storeも削除する。既存設定・無関係な作業差分は保つ。停止またはroom操作に失敗した場合は再実行用の記録を残してエラーを返す。 -親は呼出し元のセッションに着卓し、親宛DMの監視イベントを実際に受信して確認する。親の耳疎通、kickoffの引受確認、席設定変更の手順は[同梱スキル](skill/SKILL.md)にまとめている。 +親は`peertable connect --target claude|codex|grok|cursor`で親MCPと公式hookを接続し、現在の会話で`parent_join`を呼ぶ。親宛DM・複数人宛・all全件を原文のまま受信する。Cursor/Grokは返却された完成済みnative背景tool入力を登録する。接続設定だけで着卓完了とせず、耳疎通と現在の受信継続を確認する。12組合せの製品実機受入は[進行中の計画](https://github.com/kitepon/peertable/blob/main/docs/plan_parent-native-delivery.md)で管理している。親の耳疎通、kickoffの引受確認、席設定変更の手順は[同梱スキル](skill/SKILL.md)にまとめている。 **roomがメンバーの唯一の台帳。** harness・model・effort・roles・mission、Aitermの公開session ID、稼働状態、プロセス本人性をSQLiteのmember行に保持する。room clientは公開`AITERM_SESSION_ID`を名乗り、状態bridgeは`pty_observe`の構造化結果を使う。PeertableはAitermの内部ファイル、socket、namespace、画面文言を解析しない。 diff --git a/README.md b/README.md index 1c625914..1650e35a 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ setupの再実行も既存projectではresumeへ進み、room・議題・生存 teardownの既定は解散。席と所有する足場を撤去し、roomと過去ログ、Lattice storeを残す。`--purge`はroomと新設storeも削除する。既存設定・無関係な作業差分は保つ。停止またはroom操作に失敗した場合は再実行用の記録を残してエラーを返す。 -親は呼出し元のセッションに着卓し、親宛DMの監視イベントを実際に受信して確認する。親の耳疎通、kickoffの引受確認、席設定変更の手順は[同梱スキル](skill/SKILL.md)にまとめている。 +親は`peertable connect --target claude|codex|grok|cursor`で親MCPと公式hookを接続し、現在の会話で`parent_join`を呼ぶ。親宛DM・複数人宛・all全件を原文のまま受信する。Cursor/Grokは返却された完成済みnative背景tool入力を登録する。接続設定だけで着卓完了とせず、耳疎通と現在の受信継続を確認する。12組合せの製品実機受入は[進行中の計画](https://github.com/kitepon/peertable/blob/main/docs/plan_parent-native-delivery.md)で管理している。親の耳疎通、kickoffの引受確認、席設定変更の手順は[同梱スキル](skill/SKILL.md)にまとめている。 **roomがメンバーの唯一の台帳。** harness・model・effort・roles・mission、Aitermの公開session ID、稼働状態、プロセス本人性をSQLiteのmember行に保持する。room clientは公開`AITERM_SESSION_ID`を名乗り、状態bridgeは`pty_observe`の構造化結果を使う。PeertableはAitermの内部ファイル、socket、namespace、画面文言を解析しない。 diff --git a/docs/current-design.md b/docs/current-design.md index 6577518d..0a238730 100644 --- a/docs/current-design.md +++ b/docs/current-design.md @@ -89,3 +89,9 @@ CIはこのリポジトリ内の再利用workflowを製品正本とする。外 ## 9. 文書寿命 現行の入口は[文書地図](00_overview.md)である。`docs/`直下には現行契約と状態照合中のcampaignだけを置く。完了・supersededした計画と累積decision logは`docs/archive/`へ移し、通常の読書順から外す。固定consumerが実在する時だけ、旧pathへ短い互換案内を残す。 + +## 親の公式受信口 + +親のdelivery.kindはparent_receiverとし、通常席bridgeから除外する。Peertableは原文spoolと3親MCP tool、Claude asyncRewake、Codex公式queueと同期hook、Cursor公式hookとnative背景受信、Grok native背景完了と本文回収を所有する。allは親にも全件配送し、他席間DMと自発言は対象外。旧parent_watchは移行診断の対象。受付不明はunknownを保持し、queue受付ID・時刻を消さず本文を自動再送しない。 + +このcampaignの製品実機受入・公開は未完了。[親配送計画](plan_parent-native-delivery.md)の12組合せと全必須実行面を受入するまで公開判定を行わない。 diff --git a/docs/plan_parent-native-delivery.md b/docs/plan_parent-native-delivery.md index 9aa302f2..ba604235 100644 --- a/docs/plan_parent-native-delivery.md +++ b/docs/plan_parent-native-delivery.md @@ -1,6 +1,6 @@ # 親セッションへの配送設計 -状態: 設計。製品コードは未変更。実装と12組合せの実機受入は未実施。 +状態: 実装着手。allは親にも全件配送する。12組合せの実機受入は未実施。 ## 1. 依頼と完了条件 @@ -70,9 +70,9 @@ flowchart LR ### 全体メッセージの通知条件 -現行`parentWatchShouldNotify`は、全体メッセージのうち「全タスク完了」または「[オーナー宛」を含むものだけ親を起こし、他はquietとしてcursorを進める。これは配送技術とは別の仕様である。 +`all`は親にも全件配送する。親宛DM、親を含む複数人宛と同じ配送処理を使う。他のメンバー同士のDMは親へ配送しない。 -設計提案は通常メンバーと同じ全件配送とする。オーナーへこの選択を確認中であり、回答前に製品の通知条件を変更しない。現行条件を維持する回答であれば、この節をその確定仕様に置き換え、quietと配送失敗を区別する。どちらの場合も、対象メッセージの配送方式・12組合せ・受入条件は減らさない。 +旧`parentWatchShouldNotify`の「全タスク完了」または「[オーナー宛」だけを起こす本文条件は、親への配送対象の定義から外す。Latticeのquiet観測・停滞警報・probeは別のイベントとして既存契約を保つ。 自発言、無用な待機自己DMの扱いは既存の共通条件を使う。本文に含まれる命令を配送者が実行しない。roomメッセージであることと送信者を明示して親へ渡す。 diff --git a/experiments/delivery-receipt-repro.mjs b/experiments/delivery-receipt-repro.mjs index 2b4825b0..097c1b1a 100644 --- a/experiments/delivery-receipt-repro.mjs +++ b/experiments/delivery-receipt-repro.mjs @@ -4,11 +4,14 @@ // 保存 receipt と配送 receipt の分離(決定102)を固定する。 import { spawn } from 'node:child_process' import { once } from 'node:events' -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { createServer } from 'node:net' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' +import { ParentSpool } from '../skill/scripts/parent-delivery.mjs' +import { processIdentity } from '../skill/scripts/parent-platform.mjs' +import { RoomApi } from '../skill/scripts/room-api.mjs' const REPO = dirname(dirname(fileURLToPath(import.meta.url))) const TOKEN = 'delivery-receipt-repro-token' @@ -96,6 +99,45 @@ try { check('result 語彙外は 400', (await post('deliveries', { seq: 1, recipient: 'mio', result: 'ok' })).status === 400) check('seq 不正は 400', (await post('deliveries', { seq: 0, recipient: 'mio', result: 'delivered' })).status === 400) check('存在しない seq の照会は 404', (await fetch(`${base}/deliveries?seq=9999`)).status === 404) + // native親も通常recipient。受付証拠はunknown更新後も不変。 + const endpoint = 'd8b62c71-85d6-40ae-834e-3560635f5230' + await post('members', { name: 'native-parent', delivery: { kind: 'parent_receiver', harness: 'codex', endpoint_id: endpoint } }) + const native = await (await post('messages', { from: 'mio', to: 'all', body: '全件配送\n日本語' })).json() + check('native親もallの宛先', native.delivery['native-parent']?.state === 'pending') + const receipt = { seq: native.seq, recipient: 'native-parent', route: 'parent_receiver', endpoint_id: endpoint, receipt_revision: 1, result: 'delivered', queued_submission_id: 'official-queue-id', accepted_at: '2026-09-28T00:00:00Z' } + check('native受付receipt', (await post('deliveries', receipt)).ok) + check('異なる受付IDはtyped reject', (await post('deliveries', { ...receipt, queued_submission_id: 'wrong-queue-id' })).status === 409) + check('異なる受付時刻はtyped reject', (await post('deliveries', { ...receipt, accepted_at: '2026-09-28T01:00:00Z' })).status === 409) + await post('deliveries', { seq: native.seq, recipient: 'native-parent', result: 'unknown', route: 'parent_receiver', endpoint_id: endpoint, receipt_revision: 2, reason: 'hook_output_unknown' }) + const unknown = (await (await fetch(`${base}/deliveries?seq=${native.seq}`)).json()).delivery['native-parent'] + check('unknownは公開され受付証拠が残る', unknown.state === 'unknown' && unknown.queued_submission_id === receipt.queued_submission_id && unknown.accepted_at === receipt.accepted_at) + // AのHTTPを保留し、別processのBがunknownを先に確定してから古いAを後着させる。 + const racedMessage = await (await post('messages', { from: 'mio', to: 'native-parent', body: 'receipt順序反転' })).json() + const credential = join(root, 'receipt-token'); writeFileSync(credential, TOKEN, { mode: 0o600 }) + const spool = ParentSpool.create(root, { endpoint_id: endpoint, name: 'native-parent', room: ROOM, server_url: `http://127.0.0.1:${port}`, credential, start_seq: 0, harness: 'codex', caller: { conversation: 'fixture', owner: processIdentity(process.pid) } }) + spool.saveEvent({ type: 'parent_dm', seq: racedMessage.seq, body: 'receipt順序反転' }) + const record = spool.claim('codex_queue'), accepted = { queued_submission_id: 'race-official-id', accepted_at: '2026-09-28T02:00:00Z' } + spool.finish(record, accepted) + const roomApi = new RoomApi(spool.read(), { credential }); let releaseA, startedA + const started = new Promise(resolve => { startedA = resolve }), delayA = new Promise(resolve => { releaseA = resolve }) + const flushA = spool.flushReceipts({ request: async (path, args) => { startedA(); await delayA; return roomApi.request(path, args) } }) + await started + const code = `import {ParentSpool} from ${JSON.stringify(new URL('../skill/scripts/parent-delivery.mjs', import.meta.url).href)};import {RoomApi} from ${JSON.stringify(new URL('../skill/scripts/room-api.mjs', import.meta.url).href)};const s=new ParentSpool(process.argv[1],process.argv[2]);s.finish(s.read().records[0],{state:'unknown',reason:'hook_output_unknown'});await s.flushReceipts(new RoomApi(s.read(),{credential:s.read().credential}));` + const childB = spawn(process.execPath, ['--input-type=module', '-e', code, root, endpoint], { stdio: ['ignore', 'ignore', 'pipe'] }); let childError = '' + childB.stderr.on('data', chunk => { childError += chunk }) + const [childStatus] = await once(childB, 'exit'); if (childStatus !== 0) throw new Error(childError) + releaseA(); await flushA + const raced = (await (await fetch(`${base}/deliveries?seq=${racedMessage.seq}`)).json()).delivery['native-parent'] + check('別processのunknown後に古いdeliveredが到着しても消さない', raced.state === 'unknown' && raced.receipt_revision === 2 && spool.read().records[0].state === 'unknown' && spool.read().records[0].receipt.pending === false) + spool.finish(record, { ...accepted, resolve_unknown: true, reason: 'official_output_complete' }); await spool.flushReceipts(roomApi) + const resolved = (await (await fetch(`${base}/deliveries?seq=${racedMessage.seq}`)).json()).delivery['native-parent'] + check('公式出力証拠の新revisionはunknownを解消し受付ID/時刻を保つ', resolved.state === 'delivered' && resolved.receipt_revision === 3 && resolved.queued_submission_id === accepted.queued_submission_id && resolved.accepted_at === accepted.accepted_at) + await post('bridges', { kind: 'parent_receiver', recipient: 'native-parent', endpoint_id: endpoint, state: 'rearm_pending' }) + const health = (await (await fetch(`${base}/members`)).json()).bridges.parent_receiver + check('再武装不足はhealthyにしない', health.state === 'rearm_pending') + await post('members', { name: 'native-parent', delivery: { kind: 'parent_receiver', harness: 'codex', endpoint_id: 'a5b18e30-a4bd-4d99-81db-e24c2789a726' } }) + check('旧endpointのreceiptは拒否', (await post('deliveries', receipt)).status === 409) + check('旧endpointのhealthは拒否', (await post('bridges', { kind: 'parent_receiver', recipient: 'native-parent', endpoint_id: endpoint, state: 'armed' })).status === 409) } finally { server.kill('SIGTERM') await Promise.race([once(server, 'exit'), sleep(1000)]) diff --git a/experiments/http-utf8-body-repro.mjs b/experiments/http-utf8-body-repro.mjs new file mode 100644 index 00000000..afaa3228 --- /dev/null +++ b/experiments/http-utf8-body-repro.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +// 罠: HTTPのBuffer断片を個別に文字列化すると、分割されたUTF-8が保存前にU+FFFDへ変わる。 +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { request } from 'node:http' +import { createServer } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { setTimeout as delay } from 'node:timers/promises' + +const root = mkdtempSync(join(tmpdir(), 'peertable-http-utf8-')) +const probe = createServer() +await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve)) +const port = probe.address().port +await new Promise(resolve => probe.close(resolve)) +const token = 'utf8-fixture-token', room = 'utf8-fixture' +const server = spawn(process.execPath, [fileURLToPath(new URL('../room/server.mjs', import.meta.url))], { + env: { ...process.env, PEERTABLE_PORT: String(port), PEERTABLE_DATA: root, PEERTABLE_POST_TOKEN: token }, stdio: ['ignore', 'ignore', 'pipe'], +}) +let stderr = ''; server.stderr.on('data', data => { stderr += data }) +const base = `http://127.0.0.1:${port}/api/${room}` +try { + let ready = false + for (let attempt = 0; attempt < 100; attempt++) { + try { ready = (await fetch(`${base}/messages`)).ok } catch {} + if (ready) break + if (server.exitCode !== null) throw new Error(stderr) + await delay(25) + } + assert.ok(ready, `roomが起動しません: ${stderr}`) + const prefix = 'あ'.repeat(24130) + '用😀', suffix = '\n"原文末尾" PEERTABLE_UTF8_END😀' + const body = prefix + 'い'.repeat(40097 - prefix.length - suffix.length) + suffix + const encoded = Buffer.from(JSON.stringify({ from: 'probe', to: 'all', body })) + const japanese = encoded.indexOf(Buffer.from('用')), emoji = encoded.indexOf(Buffer.from('😀')) + const boundaries = [0, japanese + 1, japanese + 2, emoji + 1, emoji + 2, emoji + 3, encoded.length] + const posted = await new Promise((resolve, reject) => { + const req = request(`${base}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Peertable-Token': token, 'Content-Length': encoded.length } }, res => { + res.setEncoding('utf8'); let data = '' + res.on('data', chunk => { data += chunk }); res.on('error', reject) + res.on('end', () => { try { assert.equal(res.statusCode, 200, data); resolve(JSON.parse(data)) } catch (error) { reject(error) } }) + }) + req.on('error', reject) + // 各writeを別HTTP data eventへ渡し、日本語とemojiの途中で境界を固定する。 + ;(async () => { + for (let i = 1; i < boundaries.length; i++) { req.write(encoded.subarray(boundaries[i - 1], boundaries[i])); await delay(50) } + req.end() + })().catch(reject) + }) + assert.ok(posted.body === body, 'POST応答の原文がUTF-8断片境界で変化しました') + const response = await (await fetch(`${base}/messages`)).json() + assert.ok(response.messages.find(message => message.seq === posted.seq).body === body, 'GET messagesの原文が変化しました') + const saved = readFileSync(join(root, room, 'log.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line)) + assert.ok(saved.find(message => message.seq === posted.seq).body === body, '保存logの原文が変化しました') + console.log(JSON.stringify({ schema: 'peertable.http-utf8-repro.v1', passed: true, original_characters: body.length, split_boundaries: boundaries.length - 2, checks: ['post', 'messages', 'log'] })) +} finally { + if (server.exitCode === null) { const closed = once(server, 'close'); server.kill('SIGTERM'); await closed } + rmSync(root, { recursive: true, force: true }) +} diff --git a/experiments/parent-role-repro.mjs b/experiments/parent-role-repro.mjs index e3b6c839..e2fc01ab 100644 --- a/experiments/parent-role-repro.mjs +++ b/experiments/parent-role-repro.mjs @@ -3,15 +3,17 @@ // // 確認すること: // 1. setup.sh が .team/roles/parent.md を生成する(vendor を問わず読める role 文書) -// 2. parent-join.sh は mode=lattice の setup-state.json では .team/parent-env.sh を生成し、 +// 2. 共通接続入口は mode=lattice の setup-state.json では actorファイルを生成し、 // LATTICE_TODO_ACTOR_HOST/SESSION/AGENT を親名で export する // (owner裁定[46]④: 子processのexportは親shellへ伝播しないため、親が自分でsourceする // 持続ファイルが要る) // 3. mode=standalone では parent-env.sh を生成しない(Lattice を持ち込まない卓を汚さない) -// 4. vendor=codex を渡すと通常席のdescriptorでなくparent_watch契約が登録される +// 4. 実caller無しではHTTP親登録をせず、実MCP joinの完成済み引数を返す // 5. token 未設定の新 shell が parent.md の再着卓ブロックだけで正規 config を source し、 // 秘密値を出力せず Unicode room の read / post へ到達する import assert from 'node:assert/strict'; +import { prepareParent } from '../skill/scripts/parent-prepare.mjs'; +import { scaffoldProject } from '../skill/scripts/project-scaffold.mjs'; import { spawn } from 'node:child_process'; import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import http from 'node:http'; @@ -68,7 +70,7 @@ async function fixtureServer() { request.on('end', () => { const message = { seq: messages.length + 1, ...JSON.parse(body) }; messages.push(message); - response.end(JSON.stringify({ ok: true, message })); + response.end(JSON.stringify({ ...message, room_saved: true, delivery: {} })); }); return; } @@ -103,8 +105,8 @@ try { const tasksFile = path.join(work, 'tasks.txt'); await writeFile(tasksFile, '- ダミー: 何もしない\n'); - const setupResult = await run('bash', [SETUP, proj, 'fixture-room', 'http://127.0.0.1:1', '-', ROOT, tasksFile]); - check('setup.sh(単独)が成功する', setupResult.code === 0, setupResult.stderr.trim().slice(-300)); + const setupResult = scaffoldProject({ project: proj, room: 'fixture-room', url: 'http://127.0.0.1:1', tasks: tasksFile }); + check('共通scaffoldの役割生成が成功する', setupResult.action === 'created'); const parentMd = await readFile(path.join(proj, '.team/roles/parent.md'), 'utf8').catch(() => null); check('.team/roles/parent.md が生成される', parentMd !== null); check('parent.md が親の行わないことを明記', parentMd?.includes('親が行わないこと') ?? false); @@ -118,47 +120,26 @@ try { await mkdir(path.join(latticeProj, '.team'), { recursive: true }); await writeFile(path.join(latticeProj, '.team/setup-state.json'), JSON.stringify({ room: 'r', server_url: url, mode: 'lattice' })); const childEnv = { PEERTABLE_POST_TOKEN: 'x' }; - const joinResult = await run('bash', [PARENT_JOIN, latticeProj, 'nagi-test', '', '', 'codex'], { env: childEnv }); - check('parent-join.sh(lattice)が成功する', joinResult.code === 0, joinResult.stderr.trim().slice(-300)); - const envFile = await readFile(path.join(latticeProj, '.team/parent-env.sh'), 'utf8').catch(() => null); - check('mode=lattice で .team/parent-env.sh が生成される', envFile !== null); - check('parent-env.sh が LATTICE_TODO_ACTOR_* を親名でexportする', - (envFile?.includes('LATTICE_TODO_ACTOR_HOST=mac') && envFile?.includes('LATTICE_TODO_ACTOR_SESSION=nagi-test') && envFile?.includes('LATTICE_TODO_ACTOR_AGENT=nagi-test')) ?? false); - check('vendor=codex が member 登録へ反映される', registered.some((m) => m.vendor === 'codex')); - const codexMember = registered.find((m) => m.name === 'nagi-test'); - check('Codex親は通常席descriptorでなくparent_watchを自己申告する', - codexMember?.observe === null - && codexMember?.delivery?.kind === 'parent_watch' - && codexMember?.delivery?.host === 'codex', - JSON.stringify(codexMember)); - check('Codex親へbackground watcherの起動要求を返す', - joinResult.stdout.includes('PARENT_WATCH_START_REQUIRED') - && joinResult.stdout.includes('codex-parent-watch.sh') - && joinResult.stdout.includes('1秒ごと') - && joinResult.stdout.includes('端末sessionは常駐させない') - && !joinResult.stdout.includes('--follow') - && !joinResult.stdout.includes('wakeup-bridge')); - - const grokJoin = await run('bash', [PARENT_JOIN, latticeProj, 'bell-grok', 'grok-4.6', '', 'grok'], { env: childEnv }); - check('parent-join.sh(grok)が成功する', grokJoin.code === 0, grokJoin.stderr.trim().slice(-300)); - const grokMember = registered.find((m) => m.name === 'bell-grok'); - check('Grok親は通常席descriptorでなくparent_watchを自己申告する', - grokMember?.observe === null - && grokMember?.delivery?.kind === 'parent_watch' - && grokMember?.delivery?.host === 'grok', - JSON.stringify(grokMember)); - check('Grok親へMonitor番犬の起動要求を返しwakeup-bridgeへ載せない', - grokJoin.stdout.includes('PARENT_WATCH_START_REQUIRED') - && grokJoin.stdout.includes('--follow') - && grokJoin.stdout.includes('Grok Monitor') - && grokJoin.stdout.includes('wakeup-bridgeに親を載せない')); + // global設定はこのfocused fixtureでは変更せず、正式登録は実MCP試験が担当する。 + const prepare = (args) => prepareParent(args, { connect: async () => ({ status: 'registered' }) }); + const joinResult = await prepare([latticeProj, 'nagi-test', '', '', 'codex']); + check('共通接続入口は実MCP joinの完成済み引数を返す', joinResult.error_code === 'PARENT_JOIN_REQUIRED' + && joinResult.arguments.project === latticeProj && joinResult.arguments.name === 'nagi-test'); + const actor = JSON.parse(await readFile(path.join(latticeProj, '.team/parent-env.json'), 'utf8')); + check('mode=latticeのactorを親名で保存する', actor.LATTICE_TODO_ACTOR_SESSION === 'nagi-test' + && actor.LATTICE_TODO_ACTOR_AGENT === 'nagi-test'); + check('shellのsource入力はOS adapterが返す', joinResult.actor_environment.command.startsWith('. ')); + check('実caller無しではHTTP親登録をしない', registered.length === 0); + const grokJoin = await prepare([latticeProj, 'bell-grok', 'grok-4.6', '', 'grok']); + check('Grokも同じ実MCP join契約へ渡す', grokJoin.state === 'binding_pending' + && grokJoin.arguments.name === 'bell-grok' && grokJoin.arguments.model === 'grok-4.6'); // 3. mode=standalone では parent-env.sh を作らない const standaloneProj = path.join(work, 'standalone-proj'); await mkdir(path.join(standaloneProj, '.team'), { recursive: true }); await writeFile(path.join(standaloneProj, '.team/setup-state.json'), JSON.stringify({ room: 'r', server_url: url, mode: 'standalone' })); - const joinResult2 = await run('bash', [PARENT_JOIN, standaloneProj, 'bell'], { env: childEnv }); - check('parent-join.sh(standalone)が成功する', joinResult2.code === 0, joinResult2.stderr.trim().slice(-300)); + const joinResult2 = await prepare([standaloneProj, 'bell']); + check('単独卓もMCP join準備へ渡す', joinResult2.error_code === 'PARENT_JOIN_REQUIRED'); const envFile2 = await readFile(path.join(standaloneProj, '.team/parent-env.sh'), 'utf8').catch(() => null); check('mode=standalone では .team/parent-env.sh を作らない', envFile2 === null); diff --git a/experiments/windows-parent-member-json-repro.mjs b/experiments/windows-parent-member-json-repro.mjs index 6b2372d0..8eb7b253 100644 --- a/experiments/windows-parent-member-json-repro.mjs +++ b/experiments/windows-parent-member-json-repro.mjs @@ -3,9 +3,10 @@ import { spawnSync } from 'node:child_process' import { fileURLToPath } from 'node:url' const script = fileURLToPath(new URL('../skill/scripts/platform/windows/parent-member-json.mjs', import.meta.url)) -const result = spawnSync(process.execPath, [script, 'bell', '', '', 'codex', 'オーナー窓口'], { encoding: 'utf8' }) +const result = spawnSync(process.execPath, [script, 'bell', '', '', 'codex', 'オーナー窓口', '80b88d37-6e81-45d3-8626-ed465f9744ea'], { encoding: 'utf8' }) assert.equal(result.status, 0, result.stderr) const member = JSON.parse(result.stdout) assert.deepEqual(member.roles, ['統括']) assert.equal(member.mission, 'オーナー窓口') +assert.equal(member.delivery.kind, 'parent_receiver') console.log('windows parent member JSON repro: 2/2 green') diff --git a/package.json b/package.json index 15b930e6..523701d0 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "scripts": { "postinstall": "node skill/scripts/install-skill.mjs --postinstall", "verify:release-commit": "node scripts/verify-release-commit.mjs", + "verify:parent-delivery": "node scripts/parent-delivery-acceptance.mjs rag/parent-delivery/product-acceptance.json", "test:docs": "node --test scripts/docs-contract.test.mjs", "prepublishOnly": "node scripts/prepublish.mjs" }, @@ -39,6 +40,8 @@ ".github/og.png", "room/server.mjs", "room/client.mjs", + "room/parent-client.mjs", + "room/parent-kind.mjs", "room/message-bounds.mjs", "room/Dockerfile", "skill/", diff --git a/rag/parent-delivery/product-acceptance.json b/rag/parent-delivery/product-acceptance.json new file mode 100644 index 00000000..1f6e357d --- /dev/null +++ b/rag/parent-delivery/product-acceptance.json @@ -0,0 +1,4 @@ +{ + "schema": "peertable.parent-product-acceptance.v1", + "records": [] +} diff --git a/room/Dockerfile b/room/Dockerfile index c8efb7bd..b0018cbe 100644 --- a/room/Dockerfile +++ b/room/Dockerfile @@ -1,7 +1,7 @@ # node:sqlite(member 台帳)が flag なしで使える版 FROM node:24-alpine WORKDIR /app -COPY server.mjs . +COPY server.mjs parent-kind.mjs . ENV PEERTABLE_PORT=8790 PEERTABLE_DATA=/data EXPOSE 8790 CMD ["node", "server.mjs"] diff --git a/room/client.mjs b/room/client.mjs index 80209122..e2dca2af 100755 --- a/room/client.mjs +++ b/room/client.mjs @@ -28,6 +28,11 @@ const USAGE = `usage: // 診断のコードは一切走らない(起動ディレイを増やさない) const sub = process.argv[2] if (sub !== undefined) { + if (sub === 'parent') { + await (await import('./parent-client.mjs')).runParentClient() + await new Promise(resolve => process.stdin.once('end', resolve)) + process.exit(0) + } if (sub === '-h' || sub === '--help') { process.stdout.write(USAGE) process.exit(0) @@ -111,7 +116,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ { name: 'read_unread', description: 'room全体宛と自分宛の未読メッセージを読む。読んだ位置は記憶される', inputSchema: { type: 'object', properties: {} } }, { name: 'read_log', description: 'roomログの直近count件を読む(既定20・最大20・UTF-8 12KB上限。全宛先を含む)', inputSchema: { type: 'object', properties: { count: { type: 'number' } } } }, { name: 'members', description: 'room に居るメンバーの一覧(名前・役割・設定・使命・実効稼働状態)と bridge 健全性。状態が unknown の席と bridge 障害はここで分かる', inputSchema: { type: 'object', properties: {} } }, - { name: 'delivery_status', description: '発言 seq の宛先別配達状態を照会する。delivered / pending / seat_unavailable / bridge_unavailable / failed。room_saved は配達成功ではない', inputSchema: { type: 'object', properties: { seq: { type: 'number' } }, required: ['seq'] } }, + { name: 'delivery_status', description: '発言 seq の宛先別配達状態を照会する。delivered / pending / seat_unavailable / bridge_unavailable / failed / unknown。room_saved は配達成功ではない', inputSchema: { type: 'object', properties: { seq: { type: 'number' } }, required: ['seq'] } }, ], })) diff --git a/room/parent-client.mjs b/room/parent-client.mjs new file mode 100644 index 00000000..342d1842 --- /dev/null +++ b/room/parent-client.mjs @@ -0,0 +1,84 @@ +// 親専用stdio MCP。通常席clientのchannelや読了cursorとは独立した3tool。 +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { fileURLToPath } from 'node:url' +import { readJson, hookCommand, failure } from '../skill/scripts/parent-platform.mjs' +import { clientHarness, consumeCaller, endpointById, verifyCaller, saveJoinResult } from '../skill/scripts/parent-caller.mjs' +import { joinEndpoint, setupFor, stopEndpoint, actorEnvironment } from '../skill/scripts/parent-runtime.mjs' +import { waitReceipt } from '../skill/scripts/parent-receivers/background.mjs' +import { checkCodexReceiver } from '../skill/scripts/parent-receivers/codex.mjs' +import { RoomApi } from '../skill/scripts/room-api.mjs' +import { runtimeDigest } from '../skill/scripts/runtime-digest.mjs' + +const hookEntry = fileURLToPath(new URL('../skill/scripts/parent-hook.mjs', import.meta.url)) +const commonProperties = { hook_context_id: { type: 'string', description: 'Peertable公式hook専用。モデルは省略する。' } } +const tools = [ + { name: 'parent_join', description: '既存のこの会話を親receiverとして登録・再武装する。必要な背景toolの完成済み入力を返す。', inputSchema: { type: 'object', properties: { project: { type: 'string' }, name: { type: 'string' }, model: { type: 'string' }, effort: { type: 'string' }, mission: { type: 'string' }, ...commonProperties }, required: ['project', 'name'], additionalProperties: false } }, + { name: 'parent_read', description: 'この会話の親配送本文を回収する。継続tokenがある間は全文の受付は未確定。必要な次の背景tool入力も返す。', inputSchema: { type: 'object', properties: { endpoint_id: { type: 'string' }, delivery_id: { type: 'string' }, continuation_token: { type: 'string' }, ...commonProperties }, required: ['endpoint_id'], additionalProperties: false } }, + { name: 'parent_leave', description: 'この会話の受信登録だけを閉じる。円卓・親harnessを終了しない。', inputSchema: { type: 'object', properties: { endpoint_id: { type: 'string' }, ...commonProperties }, required: ['endpoint_id'], additionalProperties: false } }, +] +export async function runParentClient({ resolveCaller = consumeCaller, resolveEndpoint = endpointById } = {}) { + const version = readJson(fileURLToPath(new URL('../package.json', import.meta.url))).version + const sourceDigest = runtimeDigest() + const server = new Server({ name: 'peertable_parent', version }, { capabilities: { tools: {} }, instructions: 'Peertableの親登録はparent_join。wait_process.native_toolがある場合は完成済みinputをそのnative背景toolへ渡す。背景完了にdelivery_idがあるときはparent_readで回収し、continuation_tokenがある間は同じ配送を続けて回収する。次のwait_processも登録する。期限controlは同じparent_join/parent_readで再武装する。hook_context_idは生成・転記しない。' }) + const writes = new Map() + server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools })) + server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { + try { + const requestId = extra.requestId + const harness = clientHarness(server.getClientVersion()), caller = resolveCaller(harness, request, requestId) + caller.peertable_source_digest = sourceDigest + if (sourceDigest !== runtimeDigest()) throw failure('PARENT_RESTART_REQUIRED') + const args = request.params.arguments ?? {}, name = request.params.name + let result + if (name === 'parent_join') { + if (!args.project || !args.name) throw failure('PARENT_JOIN_ARGUMENT_INVALID') + if (harness === 'codex') await checkCodexReceiver(args.project, caller, hookCommand(process.execPath, [hookEntry, 'codex'])) + const display = Object.fromEntries(['model', 'effort', 'mission'].filter(key => args[key] !== undefined).map(key => [key, args[key]])) + const spool = await joinEndpoint(args.project, args.name, caller, display) + result = { schema: 'peertable.parent-join-result.v1', endpoint_id: spool.id, state: spool.read().state, harness, wait_process: ['cursor', 'grok'].includes(harness) ? waitReceipt(spool) : null, actor_environment: actorEnvironment(spool.project, args.name), error_code: null } + if (harness !== 'codex') saveJoinResult(caller, result) + } else if (name === 'parent_read') { + const spool = resolveEndpoint(args.endpoint_id) + verifyCaller(spool, caller) + await spool.assertCurrentEndpoint() + spool.recover() + const page = spool.page(args.delivery_id, args.continuation_token) + result = { schema: 'peertable.parent-read-result.v1', endpoint_id: spool.id, page: page?.result ?? null, wait_process: ['cursor', 'grok'].includes(harness) ? waitReceipt(spool) : null } + if (page) writes.set(requestId, { spool, page }) + } else if (name === 'parent_leave') { + const spool = resolveEndpoint(args.endpoint_id) + verifyCaller(spool, caller) + await stopEndpoint(spool) + const api = new RoomApi(setupFor(spool.project), { credential: spool.read().credential }) + if ((await api.members()).some(member => member.delivery?.endpoint_id === spool.id)) await api.request(`members/${encodeURIComponent(spool.read().name)}`, { method: 'DELETE' }) + result = { schema: 'peertable.parent-leave-result.v1', endpoint_id: spool.id, state: 'stopped' } + } else throw failure('PARENT_TOOL_UNKNOWN') + return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result } + } catch (error) { + const result = { schema: 'peertable.parent-error.v1', state: 'failed', error_code: error.code ?? 'PARENT_REQUEST_FAILED', detail: error.message } + return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result, isError: true } + } + }) + const transport = new StdioServerTransport() + // SDKのbuffer受付だけでackせず、実stdoutのwrite callback後に最終pageを確定する。 + transport.send = message => new Promise((resolve, reject) => { + process.stdout.write(`${JSON.stringify(message)}\n`, async error => { + const pending = writes.get(message.id) + writes.delete(message.id) + if (error) { + if (pending) pending.spool.finish(pending.page.record, { state: 'unknown', reason: 'PARENT_MCP_OUTPUT_UNKNOWN' }) + reject(error); return + } + if (pending) { + try { + pending.spool.pageWritten(pending.page) + await pending.spool.flushReceipts(new RoomApi(setupFor(pending.spool.project), { credential: pending.spool.read().credential })) + } catch (receiptError) { process.stderr.write(`${receiptError.code ?? 'PARENT_RECEIPT_FAILED'}: ${receiptError.message}\n`) } + } + resolve() + }) + }) + await server.connect(transport) +} diff --git a/room/parent-client.test.mjs b/room/parent-client.test.mjs new file mode 100644 index 00000000..4c861609 --- /dev/null +++ b/room/parent-client.test.mjs @@ -0,0 +1,64 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' +import { createServer } from 'node:http' +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { ParentSpool, PAGE_CHARS } from '../skill/scripts/parent-delivery.mjs' +import { processIdentity, atomicJson } from '../skill/scripts/parent-platform.mjs' + +const waitFor = async condition => { + const deadline = Date.now() + 30000 + while (!condition()) { if (Date.now() >= deadline) throw new Error('stdio完了の観測期限を超過しました'); await delay(10) } +} +test('実SDK stdioの異なるread要求IDを相関し、長文はfinal stdout完了だけでackする', async t => { + const project = mkdtempSync(join(tmpdir(), 'peertable-parent-stdio-')) + t.after(() => rmSync(project, { recursive: true, force: true })) + const spools = [], receipts = [] + const api = createServer(async (req, res) => { + let raw = ''; for await (const chunk of req) raw += chunk + res.setHeader('content-type', 'application/json') + if (req.url.endsWith('/members')) return res.end(JSON.stringify({ members: spools.map(spool => ({ name: spool.read().name, delivery: { kind: 'parent_receiver', endpoint_id: spool.id } })) })) + if (req.url.endsWith('/deliveries')) { receipts.push(JSON.parse(raw)); return res.end('{"ok":true}') } + res.statusCode = 404; res.end('{}') + }) + await new Promise(resolve => api.listen(0, '127.0.0.1', resolve)) + t.after(() => new Promise(resolve => api.close(resolve))) + const credential = join(project, 'token'); writeFileSync(credential, 'fixture', { mode: 0o600 }) + for (const name of ['long-parent', 'short-parent']) spools.push(ParentSpool.create(project, { name, room: 'fixture', start_seq: 0, server_url: `http://127.0.0.1:${api.address().port}`, credential, harness: 'codex', caller: { harness: 'codex', conversation: name, owner: processIdentity(process.pid) } })) + atomicJson(join(project, '.team', 'setup-state.json'), { room: 'fixture', server_url: spools[0].read().server_url }) + const original = '日本語😀'.repeat(PAGE_CHARS) + const long = spools[0].saveEvent({ type: 'parent_dm', seq: 1, body: original }), short = spools[1].saveEvent({ type: 'parent_dm', seq: 2, body: '短文' }) + const requestsFile = join(project, 'requests.jsonl') + // 本人照合はcaller試験が担当。この試験は実SDKのschema parse/extra/stdio完了境界を通す。 + const code = String.raw`import {runParentClient} from ${JSON.stringify(new URL('./parent-client.mjs', import.meta.url).href)};import {ParentSpool} from ${JSON.stringify(new URL('../skill/scripts/parent-delivery.mjs', import.meta.url).href)};import {appendFileSync} from 'node:fs';const project=process.argv[1];const endpoint=id=>new ParentSpool(project,id);await runParentClient({resolveEndpoint:endpoint,resolveCaller:(harness,request,requestId)=>{appendFileSync(process.argv[2],JSON.stringify({requestId,parsedId:request.id??null})+'\n');return {...endpoint(request.params.arguments.endpoint_id).read().caller,use:String(requestId)}}});` + const transport = new StdioClientTransport({ command: process.execPath, args: ['--input-type=module', '-e', code, project, requestsFile], stderr: 'pipe' }) + let stderr = ''; transport.stderr.on('data', chunk => { stderr += chunk }) + const client = new Client({ name: 'codex-mcp-client', version: 'fixture' }) + t.after(() => client.close()) + try { await client.connect(transport) } catch (error) { throw new Error(`${error.message}\n${stderr}`, { cause: error }) } + const read = (spool, delivery, token) => client.callTool({ name: 'parent_read', arguments: { endpoint_id: spool.id, delivery_id: delivery.delivery_id, ...(token ? { continuation_token: token } : {}) } }) + const [first, other] = await Promise.all([read(spools[0], long), read(spools[1], short)]) + assert.equal(first.isError, undefined, stderr); assert.equal(other.isError, undefined, stderr) + let page = first.structuredContent.page, combined = page.event.body + await waitFor(() => spools[0].read().records[0].claim.offset === page.offset + page.event.body.length && spools[1].read().records[0].state === 'submitted') + assert.equal(spools[0].read().records[0].receipt, null) + assert.ok(!receipts.some(receipt => receipt.recipient === 'long-parent')) + let pages = 1 + while (page.continuation_token) { + const next = await read(spools[0], long, page.continuation_token) + assert.equal(next.isError, undefined, stderr) + page = next.structuredContent.page; combined += page.event.body; pages++ + await waitFor(() => !spools[0].read().records[0].claim.page_inflight) + } + await waitFor(() => spools[0].read().records[0].receipt?.pending === false) + assert.equal(combined, original); assert.ok(pages > 1) + assert.equal(receipts.filter(receipt => receipt.recipient === 'long-parent').length, 1) + assert.equal(spools[0].read().records[0].state, 'submitted') + const requests = readFileSync(requestsFile, 'utf8').trim().split('\n').map(JSON.parse) + assert.equal(new Set(requests.map(request => request.requestId)).size, pages + 1) + assert.ok(requests.every(request => typeof request.requestId === 'number' && request.parsedId === null)) +}) diff --git a/room/parent-kind.mjs b/room/parent-kind.mjs new file mode 100644 index 00000000..29f6dea6 --- /dev/null +++ b/room/parent-kind.mjs @@ -0,0 +1,3 @@ +// 親の配送種別を通常席と区別する共通判定。旧方式は移行・診断対象として残す。 +export const isParentMember = member => ['parent_receiver', 'parent_watch'].includes(member?.delivery?.kind) +export const parentRecipients = members => members.filter(isParentMember).map(member => member.name) diff --git a/room/server.mjs b/room/server.mjs index ae77b6ff..b24ab837 100644 --- a/room/server.mjs +++ b/room/server.mjs @@ -6,6 +6,7 @@ import { mkdirSync, readFileSync, appendFileSync, existsSync, rmSync, readdirSyn import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { DatabaseSync } from 'node:sqlite' +import { isParentMember } from './parent-kind.mjs' const SERVER_USAGE = 'usage: peertable-room\n設定は環境変数: PEERTABLE_PORT(既定8790)/ PEERTABLE_DATA(既定./peertable-data)/ PEERTABLE_POST_TOKEN(設定時のみ書込に要求)\n' const serverArg = process.argv[2] @@ -64,6 +65,11 @@ db.exec(` room TEXT NOT NULL, seq INTEGER NOT NULL, recipient TEXT NOT NULL, result TEXT NOT NULL, reason TEXT, at TEXT NOT NULL, PRIMARY KEY (room, seq, recipient) + ); + CREATE TABLE IF NOT EXISTS parent_receivers ( + room TEXT NOT NULL, recipient TEXT NOT NULL, endpoint_id TEXT NOT NULL, + pid INTEGER, state TEXT NOT NULL, detail TEXT, beat_at TEXT NOT NULL, + PRIMARY KEY (room,recipient) ) `) @@ -73,6 +79,10 @@ db.exec(` if (!columns.includes('harness')) db.exec('ALTER TABLE members ADD COLUMN harness TEXT') if (!columns.includes('read_seq')) db.exec('ALTER TABLE members ADD COLUMN read_seq INTEGER') if (columns.includes('vendor')) db.exec('UPDATE members SET harness = vendor WHERE harness IS NULL') + const receiptColumns = db.prepare('PRAGMA table_info(deliveries)').all().map(column => column.name) + for (const column of ['queued_submission_id', 'accepted_at']) if (!receiptColumns.includes(column)) db.exec(`ALTER TABLE deliveries ADD COLUMN ${column} TEXT`) + if (!receiptColumns.includes('receipt_revision')) db.exec('ALTER TABLE deliveries ADD COLUMN receipt_revision INTEGER NOT NULL DEFAULT 0') + if (!receiptColumns.includes('receipt_endpoint_id')) db.exec('ALTER TABLE deliveries ADD COLUMN receipt_endpoint_id TEXT') } const MEMBER_COLUMNS = [ @@ -193,19 +203,30 @@ const authFailures = new Map() // (member 以外の一般書込の 403 を bridge 障害として表示しない)。 function classifyDeniedWrite(rest, body) { if (rest === 'bridges') { try { return JSON.parse(body).kind ?? null } catch { return null } } - if (rest === 'deliveries') return 'wakeup' + if (rest === 'deliveries') { try { return JSON.parse(body).route === 'parent_receiver' ? 'parent_receiver' : 'wakeup' } catch { return null } } if (rest === 'members') { try { return JSON.parse(body).usage_source === 'pane_status' ? 'seat_status' : null } catch { return null } } return null } -const BRIDGE_KINDS = ['seat_status', 'wakeup'] +const BRIDGE_KINDS = ['seat_status', 'wakeup', 'parent_receiver'] // 表示語彙はオーナー報告の障害名(status_bridge_down / wakeup_bridge_down / bridge_auth_failed) -const BRIDGE_LABEL = { seat_status: 'status', wakeup: 'wakeup' } +const BRIDGE_LABEL = { seat_status: 'status', wakeup: 'wakeup', parent_receiver: 'parent_receiver' } function bridgeHealth(roomName, now = Date.now()) { const out = {} for (const kind of BRIDGE_KINDS) { + if (kind === 'parent_receiver') { + const parents = listMembers(roomName).filter(member => member.delivery?.kind === 'parent_receiver') + const endpoints = parents.map(member => { + const row = db.prepare('SELECT * FROM parent_receivers WHERE room=? AND recipient=? AND endpoint_id=?').get(roomName, member.name, member.delivery.endpoint_id) + const state = !row ? 'parent_receiver_unreported' : now - Date.parse(row.beat_at) >= STATUS_STALE_MS ? 'parent_receiver_down' : row.state + return { recipient: member.name, endpoint_id: member.delivery.endpoint_id, state, beat_at: row?.beat_at ?? null, detail: row?.detail ?? null } + }) + const authAt = authFailures.get(`${roomName}/${kind}`) + out[kind] = { state: authAt !== undefined && now - authAt < AUTH_FAIL_FRESH_MS ? 'bridge_auth_failed' : endpoints.length && endpoints.every(endpoint => endpoint.state === 'armed') ? 'up' : endpoints.find(endpoint => endpoint.state !== 'armed')?.state ?? 'parent_receiver_unreported', endpoints } + continue + } const row = db.prepare('SELECT * FROM bridges WHERE room = ? AND kind = ?').get(roomName, kind) const beat = row ? Date.parse(row.beat_at) : null const authAt = authFailures.get(`${roomName}/${kind}`) @@ -215,7 +236,7 @@ function bridgeHealth(roomName, now = Date.now()) { } if (!row) { out[kind] = { state: `${BRIDGE_LABEL[kind]}_bridge_unreported`, beat_at: null }; continue } out[kind] = now - beat < STATUS_STALE_MS - ? { state: 'up', beat_at: row.beat_at, pid: row.pid } + ? { state: kind === 'parent_receiver' && row.state !== 'armed' ? row.state : 'up', beat_at: row.beat_at, pid: row.pid, ...(kind === 'parent_receiver' ? { detail: row.detail } : {}) } : { state: `${BRIDGE_LABEL[kind]}_bridge_down`, beat_at: row.beat_at } } return out @@ -278,7 +299,6 @@ function memberActivity(member, effective, activityLog) { // ---- 配送状態の導出(決定102)----------------------------------------------------- // receipt(wakeup-bridge の実投入記録)が正。無い宛先は member 台帳と bridge 台帳から // pending / seat_unavailable / bridge_unavailable を導出する。room_saved だけでは配達と言わない。 -const isParentMember = member => member?.delivery?.kind === 'parent_watch' // wakeup-bridge の配送対象判定(skill/scripts/wakeup-delivery.mjs の isWakeupBridgeTarget と同じ規則) function isTuiDeliveryTarget(member) { if (!member || isParentMember(member)) return false @@ -297,12 +317,14 @@ function deliveryPlanFor(roomName, msg, bridges) { const receipt = db.prepare('SELECT * FROM deliveries WHERE room = ? AND seq = ? AND recipient = ?') .get(roomName, msg.seq, name) if (receipt) { - out[name] = { state: receipt.result, ...(receipt.reason ? { reason: receipt.reason } : {}), at: receipt.at } + out[name] = { state: receipt.result, ...(receipt.reason ? { reason: receipt.reason } : {}), at: receipt.at, + ...(receipt.queued_submission_id ? { queued_submission_id: receipt.queued_submission_id } : {}), ...(receipt.accepted_at ? { accepted_at: receipt.accepted_at } : {}), + ...(receipt.receipt_revision ? { receipt_revision: receipt.receipt_revision } : {}) } continue } const member = members.get(name) if (!member) { out[name] = { state: 'seat_unavailable', reason: 'member_not_found' }; continue } - if (isParentMember(member)) { out[name] = { state: 'pending', reason: 'parent_watch経由' }; continue } + if (isParentMember(member)) { out[name] = { state: 'pending', reason: `${member.delivery.kind}経由` }; continue } if (!isTuiDeliveryTarget(member)) { out[name] = { state: 'seat_unavailable', reason: 'no_delivery_route' }; continue } if (bridges.wakeup.state !== 'up') { out[name] = { state: 'bridge_unavailable', reason: bridges.wakeup.state }; continue } out[name] = { state: 'pending' } @@ -313,7 +335,7 @@ function deliveryPlanFor(roomName, msg, bridges) { const WAITING_WORDS = ['待機する', '待機します', '待機。'] function parentName(room) { - return listMembers(room.name).find(m => m.delivery?.kind === 'parent_watch')?.name + return listMembers(room.name).find(isParentMember)?.name } // `all` は room 全体、名前は DM、配列は明示した複数人宛。いずれも同じ通常発言である。 @@ -338,7 +360,7 @@ function normalizeAudience(to, toNames) { // SSE の member イベントで押し込む欄。閲覧者が気づく欄だけに絞る(POST /members 参照)。 // roles は配列なので JSON 比較で差分を見る -const MEMBER_EVENT_FIELDS = ['status', 'busy_since', 'harness', 'model', 'effort', 'roles', 'mission'] +const MEMBER_EVENT_FIELDS = ['status', 'busy_since', 'harness', 'model', 'effort', 'roles', 'mission', 'delivery'] const memberFieldChanged = (before, after) => MEMBER_EVENT_FIELDS.some(f => JSON.stringify(before?.[f] ?? null) !== JSON.stringify(after?.[f] ?? null)) @@ -367,7 +389,8 @@ function post(room, from, to, body, toNames = null, extra = null) { const CORS = { 'Access-Control-Allow-Origin': '*' } const json = (res, code, obj, headers) => { res.writeHead(code, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)) } -const readBody = req => new Promise(r => { let b = ''; req.on('data', c => (b += c)); req.on('end', () => r(b)) }) +// UTF-8の途中でHTTP chunkが切れても、stream decoderが次の断片まで保持する。 +const readBody = req => new Promise(r => { let b = ''; req.setEncoding('utf8'); req.on('data', c => (b += c)); req.on('end', () => r(b)) }) http.createServer(async (req, res) => { const url = new URL(req.url, 'http://x') @@ -491,7 +514,14 @@ http.createServer(async (req, res) => { } // bridge 心拍(決定103)。seat-status / wakeup が 30 秒ごとに送る。正常心拍は 403 観測を消す if (req.method === 'POST' && rest === 'bridges') { - const { kind, pid, state, detail } = JSON.parse(body) + const { kind, pid, state, detail, recipient, endpoint_id } = JSON.parse(body) + if (kind === 'parent_receiver') { + const parent = listMembers(room.name).find(member => member.name === recipient) + if (parent?.delivery?.kind !== 'parent_receiver' || parent.delivery.endpoint_id !== endpoint_id) return json(res, 409, { code: 'PARENT_ENDPOINT_SUPERSEDED', error: 'parent_endpoint_superseded' }) + db.prepare(`INSERT OR REPLACE INTO parent_receivers (room,recipient,endpoint_id,pid,state,detail,beat_at) VALUES (?,?,?,?,?,?,?)`).run(room.name, recipient, endpoint_id, pid ?? null, state ?? 'failed', detail ?? null, new Date().toISOString()) + authFailures.delete(`${room.name}/parent_receiver`) + return json(res, 200, { ok: true }) + } if (typeof kind !== 'string' || kind.length === 0) return json(res, 400, { error: 'kind_required' }) db.prepare(`INSERT OR REPLACE INTO bridges (room, kind, pid, state, detail, beat_at) VALUES (?, ?, ?, ?, ?, ?)`).run(room.name, kind, pid ?? null, state ?? 'running', detail ?? null, new Date().toISOString()) @@ -502,19 +532,40 @@ http.createServer(async (req, res) => { // 消さないと空のarchive roomへstatus_bridge_down警告が恒久表示される。 if (req.method === 'DELETE' && rest === 'bridges') { db.prepare('DELETE FROM bridges WHERE room = ?').run(room.name) + db.prepare('DELETE FROM parent_receivers WHERE room = ?').run(room.name) for (const kind of BRIDGE_KINDS) authFailures.delete(`${room.name}/${kind}`) return json(res, 200, { ok: true }) } // 配送 receipt(決定102)。書き手は wakeup-bridge だけ。同じ (seq, recipient) は最新で上書き // (seat_unavailable → 再試行成功 delivered の遷移を残すため) if (req.method === 'POST' && rest === 'deliveries') { - const { seq: recSeq, recipient, result, reason } = JSON.parse(body) + const { seq: recSeq, recipient, result, reason, queued_submission_id, accepted_at, route, endpoint_id, receipt_revision } = JSON.parse(body) if (!Number.isSafeInteger(recSeq) || recSeq <= 0) return json(res, 400, { error: 'seq_required' }) if (typeof recipient !== 'string' || recipient.length === 0) return json(res, 400, { error: 'recipient_required' }) - if (!['delivered', 'pending', 'seat_unavailable', 'bridge_unavailable', 'failed'].includes(result)) + if (queued_submission_id !== undefined && queued_submission_id !== null && (typeof queued_submission_id !== 'string' || !queued_submission_id)) return json(res, 400, { code: 'PARENT_QUEUE_ACCEPTANCE_INVALID' }) + if (accepted_at !== undefined && accepted_at !== null && (typeof accepted_at !== 'string' || !Number.isFinite(Date.parse(accepted_at)))) return json(res, 400, { code: 'PARENT_QUEUE_ACCEPTANCE_INVALID' }) + const parent = getMember(room.name, recipient) + const nativeReceipt = route === 'parent_receiver' || parent?.delivery?.kind === 'parent_receiver' + if (nativeReceipt) { + if (parent?.delivery?.kind !== 'parent_receiver' || parent.delivery.endpoint_id !== endpoint_id) return json(res, 409, { code: 'PARENT_ENDPOINT_SUPERSEDED', error: 'parent_endpoint_superseded' }) + if (!Number.isSafeInteger(receipt_revision) || receipt_revision <= 0) return json(res, 400, { code: 'PARENT_RECEIPT_REVISION_INVALID' }) + } + if (!['delivered', 'pending', 'seat_unavailable', 'bridge_unavailable', 'failed', 'unknown'].includes(result)) return json(res, 400, { error: 'result_invalid' }) - db.prepare(`INSERT OR REPLACE INTO deliveries (room, seq, recipient, result, reason, at) - VALUES (?, ?, ?, ?, ?, ?)`).run(room.name, recSeq, recipient, result, reason ?? null, new Date().toISOString()) + const previous = db.prepare('SELECT * FROM deliveries WHERE room=? AND seq=? AND recipient=?').get(room.name, recSeq, recipient) + if ((queued_submission_id && previous?.queued_submission_id && queued_submission_id !== previous.queued_submission_id) + || (accepted_at && previous?.accepted_at && accepted_at !== previous.accepted_at)) return json(res, 409, { schema: 'peertable.error.v1', code: 'PARENT_QUEUE_ACCEPTANCE_CONFLICT', error: 'queue_acceptance_conflict' }) + if (nativeReceipt && previous?.receipt_endpoint_id && previous.receipt_endpoint_id !== endpoint_id) return json(res, 409, { code: 'PARENT_RECEIPT_GENERATION_CONFLICT' }) + // 状態の優劣で固定せず、所有spoolの更新順を使う。unknownの公式証拠による解消も新revisionで受ける。 + if (nativeReceipt && previous && receipt_revision < previous.receipt_revision) return json(res, 200, { ok: true, stale: true, receipt_revision: previous.receipt_revision }) + if (nativeReceipt && previous && receipt_revision === previous.receipt_revision && (previous.result !== result || previous.reason !== (reason ?? null))) return json(res, 409, { code: 'PARENT_RECEIPT_REVISION_CONFLICT' }) + db.prepare(`INSERT INTO deliveries (room, seq, recipient, result, reason, at, queued_submission_id, accepted_at, receipt_revision, receipt_endpoint_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(room,seq,recipient) DO UPDATE SET + result=excluded.result,reason=excluded.reason,at=excluded.at, + queued_submission_id=COALESCE(deliveries.queued_submission_id,excluded.queued_submission_id), + accepted_at=COALESCE(deliveries.accepted_at,excluded.accepted_at), + receipt_revision=excluded.receipt_revision,receipt_endpoint_id=excluded.receipt_endpoint_id`) + .run(room.name, recSeq, recipient, result, reason ?? null, new Date().toISOString(), queued_submission_id ?? null, accepted_at ?? null, nativeReceipt ? receipt_revision : 0, nativeReceipt ? endpoint_id : null) return json(res, 200, { ok: true }) } if (req.method === 'POST' && rest === 'members') { @@ -550,6 +601,7 @@ http.createServer(async (req, res) => { rooms.delete(room.name); rmSync(room.dir, { recursive: true, force: true }) db.prepare('DELETE FROM members WHERE room = ?').run(room.name) db.prepare('DELETE FROM bridges WHERE room = ?').run(room.name) + db.prepare('DELETE FROM parent_receivers WHERE room = ?').run(room.name) db.prepare('DELETE FROM deliveries WHERE room = ?').run(room.name) return json(res, 200, { ok: true }) } @@ -800,12 +852,12 @@ function render(m){ // bridge 障害の帯。unreported(bridge を立てない部屋・archive 部屋)は常時警告で汚さないため出さない const bridgesEl=document.getElementById('bridges') function renderBridges(bridges){ - const label={seat_status:'稼働状態bridge',wakeup:'配達bridge'} + const label={seat_status:'稼働状態bridge',wakeup:'配達bridge',parent_receiver:'親受信'} const warn=[] - for(const k of ['seat_status','wakeup']){ + for(const k of ['seat_status','wakeup','parent_receiver']){ const s=bridges&&bridges[k]&&bridges[k].state if(!s||s==='up'||s.endsWith('_unreported'))continue - warn.push(label[k]+':'+(s==='bridge_auth_failed'?'認証失敗(403・bridge_auth_failed)':'心拍途絶(停止)')) + warn.push(label[k]+':'+(s==='bridge_auth_failed'?'認証失敗(403・bridge_auth_failed)':k==='parent_receiver'?s:'心拍途絶(停止)')) } bridgesEl.textContent=warn.length?'⚠ '+warn.join(' / '):'' } diff --git a/scripts/ci-contract.test.mjs b/scripts/ci-contract.test.mjs index 1f75a1c3..4d46757a 100644 --- a/scripts/ci-contract.test.mjs +++ b/scripts/ci-contract.test.mjs @@ -8,6 +8,7 @@ const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))) const caller = readFileSync(path.join(root, '.github/workflows/ci.yml'), 'utf8') const reusable = readFileSync(path.join(root, '.github/workflows/product-full-ci.yml'), 'utf8') const manifest = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')) +const productRunner = readFileSync(path.join(root, 'scripts/run-product-ci.mjs'), 'utf8') function walk(directory) { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { @@ -16,6 +17,22 @@ function walk(directory) { }) } +test('親配送の故障試験と全entryのsyntax検査を3OS製品CIへ登録する', () => { + for (const file of ['skill/scripts/parent-delivery.test.mjs', 'skill/scripts/parent-source-runtime.test.mjs', 'room/parent-client.test.mjs', 'scripts/parent-delivery-acceptance.test.mjs', 'experiments/delivery-receipt-repro.mjs', 'experiments/http-utf8-body-repro.mjs']) assert.ok(productRunner.includes(file)) + assert.match(productRunner, /scriptsWithExtension\(path\.join\(root, directory\), '\.mjs'\)/u) + for (const directory of ['room', 'skill/scripts', 'scripts']) for (const file of walk(path.join(root, directory)).filter(file => file.endsWith('.mjs'))) { + const source = readFileSync(file, 'utf8') + if (source.includes('#!')) assert.ok(source.startsWith('#!') || !/^#!/mu.test(source), `${file}のshebangが先頭ではありません`) + } +}) + +test('公開前gateは実機受入manifestを検査し、fixture試験だけでpublishしない', () => { + const prepublish = readFileSync(path.join(root, 'scripts/prepublish.mjs'), 'utf8') + assert.match(prepublish, /scripts\/parent-delivery-acceptance\.mjs/u) + assert.match(prepublish, /rag\/parent-delivery\/product-acceptance\.json/u) + assert.equal(manifest.scripts['verify:parent-delivery'], 'node scripts/parent-delivery-acceptance.mjs rag/parent-delivery/product-acceptance.json') +}) + test('CI実行本体とMarkdown検査を製品repoが所有する', () => { assert.match(caller, /uses:\s+\.\/\.github\/workflows\/product-full-ci\.yml/u) assert.match(caller, /documentation-command:\s*>-[\s\S]*npm ci --ignore-scripts --no-audit --no-fund && npm run test:docs/u) diff --git a/scripts/parent-delivery-acceptance.mjs b/scripts/parent-delivery-acceptance.mjs new file mode 100644 index 00000000..8a717e1a --- /dev/null +++ b/scripts/parent-delivery-acceptance.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +// 必須面とscenarioの直積を生成する。fixtureのpassedを製品の実機受入へ流用しない。 +import { readFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { isDeepStrictEqual } from 'node:util' +import { runtimeDigest } from '../skill/scripts/runtime-digest.mjs' +const root = fileURLToPath(new URL('../', import.meta.url)) +export const surfaces = { claude: ['cli'], codex: ['desktop', 'ide', 'cli'], grok: ['cli'], cursor: ['desktop', 'cli'] } +export const scenarios = { + audience: 'DM・親を含む複数宛・all各1回、送信者/room/seq/宛先/原文一致', + busy: '作業を保持して同じ会話へ配送', final_race: '最終応答/終了競合で欠落重複なし', idle: 'オーナー入力なしで同じ会話が再開', + consecutive: 'seq順の連続配送と2通目以降の待機', no_external_tools: '外部作業toolなしでも受信維持', no_tools: '全tool省略時の維持または明示rearm_pendingと原文保持', + original: '日本語・改行・引用・長文の全量回収', burst: '回収上限超過の未読保持', source_reconnect: 'HTTP/SSE再接続catch-upで重複欠落なし', + process_recovery: 'ready継承/sending unknown/submitted再送なし', claim_race: '受信口間claimで1回だけ出力', output_interruption: '削除/claim後中断はunknownと原文保持', + receipt_retry: '受付後のreceipt失敗はreceiptだけ復旧', session_change: 'clear/終了/別会話/resumeで旧本文誤流入なし', parallel_rooms: '複数room/親会話の所有分離', + unavailable_hook: '無効/未承認/実行file欠落は原因付き失敗', foreign_ownership: '利用者/Aitermのqueue・hook・承認・順序保持', compatibility_hooks: '互換hookの二重相関なし', + background_end: 'task cancel/timeout/終了は成功へ丸めない', lease: '有限lease更新とendpoint/cursor/本文保持', binding_deadline: '束縛/probe期限のtyped failure', + slot_race: '複数hook開始でも1slot', lifecycle: '同版/新版更新・teardownで親processと履歴保持', package: '公開packageから同じ経路が成立', +} +export const adapterObservations = { + claude: ['実session/tool_use_idとMCP claudecode/toolUseId', 'PostToolUse/Stop asyncRewakeのnative hook_response exit2と後続assistant', 'PID+開始identityのsession1slot'], + codex: ['実MCP threadIdと実caller binary/store', 'queue受付IDと同期PostToolUse/Stopのdelete:true・同turn返信', 'idle公式queueの新turn起床・他所有queue不変更'], + cursor: ['実conversation_id/tool_use_idとupdated_input相関', 'additional_contextとnative背景Shell/task PID相関', '背景完了の同会話返信・次slot再武装'], + grok: ['実sessionId/toolUseIdとdispatch envelope updatedInput相関', 'native run_terminal_command背景task/PID相関', '正確な完了taskの公開出力取得・parent_read最終page・次slot'], +} +export function expectedCases() { + return ['darwin', 'linux', 'win32'].flatMap(os => Object.entries(surfaces).flatMap(([harness, executionSurfaces]) => executionSurfaces.flatMap(surface => Object.entries(scenarios).map(([scenario, expected]) => ({ + id: `${os}/${harness}/${surface}/${scenario}`, os, harness, surface, scenario, expected, observations: adapterObservations[harness], + }))))) +} +export function auditAcceptance(records, { sourceCommit, sourceDigest, packageVersion, readEvidence = file => JSON.parse(readFileSync(resolve(root, file), 'utf8')) } = {}) { + const byId = new Map(), errors = [] + for (const record of records) { if (byId.has(record.id)) errors.push({ id: record.id, code: 'PARENT_ACCEPTANCE_DUPLICATE' }); byId.set(record.id, record) } + const expected = expectedCases() + for (const item of expected) { + const actual = byId.get(item.id) + if (!actual) { errors.push({ id: item.id, code: 'PARENT_ACCEPTANCE_MISSING' }); continue } + if (actual.status !== 'passed' || actual.kind !== 'product_live' || actual.native !== true || !actual.harness_version || !/^[0-9a-f]{40}$/u.test(actual.source_commit ?? '') || !/^[0-9a-f]{64}$/u.test(actual.runtime_digest ?? '') || !actual.package_version || !actual.parent_session || !actual.evidence_file || actual.body_equal !== true || actual.owner_input_required !== false || !actual.receipt_evidence || !actual.observations?.length || (sourceCommit && actual.source_commit !== sourceCommit) || (sourceDigest && actual.runtime_digest !== sourceDigest) || (packageVersion && actual.package_version !== packageVersion)) errors.push({ id: item.id, code: 'PARENT_ACCEPTANCE_INVALID' }) + if (actual.kind === 'product_live' && actual.evidence_file) { + try { + const evidence = readEvidence(actual.evidence_file) + if (evidence.schema !== 'peertable.parent-live-case.v1' || evidence.case_id !== item.id || evidence.native !== true + || evidence.source_commit !== actual.source_commit || evidence.harness_version !== actual.harness_version || evidence.parent_session !== actual.parent_session + || evidence.runtime_digest !== actual.runtime_digest || evidence.package_version !== actual.package_version + || evidence.os !== item.os || evidence.harness !== item.harness || evidence.surface !== item.surface + || evidence.owner_input_required !== false || !Array.isArray(evidence.observations) || !evidence.observations.length || evidence.observations.some(event => !event.kind || event.parent_session !== actual.parent_session || !event.turn_id) + || !Array.isArray(evidence.receipts) || !evidence.receipts.length || evidence.receipts.some(receipt => !Number.isSafeInteger(receipt.seq) || receipt.seq <= 0 || !receipt.recipient || receipt.route !== 'parent_receiver' || !['delivered', 'failed', 'unknown'].includes(receipt.result) || !Number.isSafeInteger(receipt.receipt_revision) || receipt.receipt_revision <= 0 + || (item.harness === 'codex' && receipt.result === 'delivered' && (!receipt.queued_submission_id || !receipt.accepted_at))) + || !Array.isArray(evidence.body_checks) || !evidence.body_checks.length + || evidence.body_checks.some(check => !check.original || !check.received || typeof check.original.body !== 'string' + || !isDeepStrictEqual(check.original, check.received))) errors.push({ id: item.id, code: 'PARENT_ACCEPTANCE_EVIDENCE_INVALID' }) + } catch (error) { errors.push({ id: item.id, code: 'PARENT_ACCEPTANCE_EVIDENCE_UNAVAILABLE', detail: error.code ?? error.message }) } + } + if (item.scenario === 'audience' && !['dm', 'multiple', 'all'].every(audience => actual.audiences?.[audience]?.body_equal === true && actual.audiences[audience].count === 1)) errors.push({ id: item.id, code: 'PARENT_ACCEPTANCE_AUDIENCE_MISSING' }) + } + for (const id of byId.keys()) if (!expected.some(item => item.id === id)) errors.push({ id, code: 'PARENT_ACCEPTANCE_UNEXPECTED' }) + return { schema: 'peertable.parent-acceptance-audit.v1', status: errors.length ? 'failed' : 'passed', expected: expected.length, errors } +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const [file, commit] = process.argv.slice(2) + const records = JSON.parse(readFileSync(file, 'utf8')).records + const result = auditAcceptance(records, { sourceCommit: commit ?? execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(), sourceDigest: runtimeDigest(), packageVersion: JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')).version }); console.log(JSON.stringify(result)); if (result.status !== 'passed') process.exitCode = 1 +} diff --git a/scripts/parent-delivery-acceptance.test.mjs b/scripts/parent-delivery-acceptance.test.mjs new file mode 100644 index 00000000..af46a3e8 --- /dev/null +++ b/scripts/parent-delivery-acceptance.test.mjs @@ -0,0 +1,22 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { expectedCases, auditAcceptance } from './parent-delivery-acceptance.mjs' +test('全12組合せの必須実行面は欠落/skip/fixtureを製品実機passedにしない', () => { + const expected = expectedCases() + for (const os of ['darwin', 'linux', 'win32']) for (const harness of ['claude', 'codex', 'cursor', 'grok']) assert.ok(expected.some(item => item.os === os && item.harness === harness)) + assert.equal(auditAcceptance([]).errors.length, expected.length) + assert.equal(auditAcceptance([{ id: expected[0].id, status: 'passed', kind: 'fixture' }]).status, 'failed') + assert.equal(auditAcceptance([{ id: expected[0].id, status: 'skip' }]).errors[0].code, 'PARENT_ACCEPTANCE_INVALID') + assert.ok(expected.some(item => item.id === 'linux/codex/desktop/lease')) +}) +test('実機passedの宣言だけでは受けず、同source/会話/原文の証拠fileを照合する', () => { + const item = expectedCases()[0] + const actual = { ...item, status: 'passed', kind: 'product_live', native: true, harness_version: 'fixture', source_commit: '1'.repeat(40), runtime_digest: '2'.repeat(64), package_version: 'fixture', parent_session: 'session', evidence_file: 'case.json', body_equal: true, owner_input_required: false, receipt_evidence: true, observations: ['native'], audiences: Object.fromEntries(['dm', 'multiple', 'all'].map(key => [key, { body_equal: true, count: 1 }])) } + const unavailable = auditAcceptance([actual], { readEvidence: () => { throw Object.assign(new Error('missing'), { code: 'ENOENT' }) } }) + assert.ok(unavailable.errors.some(error => error.id === item.id && error.code === 'PARENT_ACCEPTANCE_EVIDENCE_UNAVAILABLE')) + const original = { room: 'fixture', from: 'mio', to_names: ['bell'], seq: 1, body: '日本語\n引用😀' } + const evidence = { schema: 'peertable.parent-live-case.v1', case_id: item.id, os: item.os, harness: item.harness, surface: item.surface, native: true, source_commit: actual.source_commit, runtime_digest: actual.runtime_digest, package_version: actual.package_version, harness_version: actual.harness_version, parent_session: actual.parent_session, owner_input_required: false, observations: [{ kind: 'native_hook_response', parent_session: actual.parent_session, turn_id: 'native-turn' }], receipts: [{ seq: 1, recipient: 'bell', route: 'parent_receiver', result: 'delivered', receipt_revision: 1 }], body_checks: [{ original, received: { ...original, body: '欠落' } }] } + assert.ok(auditAcceptance([actual], { readEvidence: () => evidence }).errors.some(error => error.id === item.id && error.code === 'PARENT_ACCEPTANCE_EVIDENCE_INVALID')) + evidence.body_checks[0].received = structuredClone(original) + assert.ok(!auditAcceptance([actual], { readEvidence: () => evidence }).errors.some(error => error.id === item.id)) +}) diff --git a/scripts/prepublish.mjs b/scripts/prepublish.mjs index bb4d94e3..27aef3df 100644 --- a/scripts/prepublish.mjs +++ b/scripts/prepublish.mjs @@ -15,4 +15,5 @@ const run = (args) => { } run(['--test', join(root, 'skill/scripts/runtime-contract.test.mjs')]) run([join(root, 'scripts/verify-release-commit.mjs')]) +run([join(root, 'scripts/parent-delivery-acceptance.mjs'), join(root, 'rag/parent-delivery/product-acceptance.json')]) run([join(root, 'room/client.mjs'), 'diagnostics']) diff --git a/scripts/run-product-ci.mjs b/scripts/run-product-ci.mjs index 0b20b8fa..3d20b2a6 100644 --- a/scripts/run-product-ci.mjs +++ b/scripts/run-product-ci.mjs @@ -16,24 +16,28 @@ function run(command, argv, options = {}) { if (result.status !== 0) process.exit(result.status ?? 1) } -function shellScripts(directory) { +function scriptsWithExtension(directory, extension) { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const file = path.join(directory, entry.name) - if (entry.isDirectory()) return shellScripts(file) - return file.endsWith('.sh') ? [file] : [] + if (entry.isDirectory()) return scriptsWithExtension(file, extension) + return file.endsWith(extension) ? [file] : [] }) } -run(process.execPath, ['--check', 'room/server.mjs']) -run(process.execPath, ['--check', 'room/client.mjs']) +for (const directory of ['room', 'skill/scripts', 'scripts']) { + for (const file of scriptsWithExtension(path.join(root, directory), '.mjs')) run(process.execPath, ['--check', file]) +} run(process.execPath, ['--test', 'skill/scripts/runtime-contract.test.mjs']) run(process.execPath, ['experiments/aiterm-unified-delivery-repro.mjs']) run(process.execPath, ['--test', 'scripts/ci-contract.test.mjs', 'scripts/docs-contract.test.mjs']) +run(process.execPath, ['--test', 'skill/scripts/parent-delivery.test.mjs', 'skill/scripts/parent-source-runtime.test.mjs', 'room/parent-client.test.mjs', 'scripts/parent-delivery-acceptance.test.mjs']) +run(process.execPath, ['experiments/delivery-receipt-repro.mjs']) +run(process.execPath, ['experiments/http-utf8-body-repro.mjs']) run(process.execPath, ['experiments/windows-seat-mux-repro.mjs']) run(process.execPath, ['experiments/seat-placement-repro.mjs']) if (process.platform !== 'win32') { - for (const file of shellScripts(path.join(root, 'skill/scripts'))) run('bash', ['-n', file]) + for (const file of scriptsWithExtension(path.join(root, 'skill/scripts'), '.sh')) run('bash', ['-n', file]) } run(process.execPath, ['room/client.mjs', 'diagnostics'], { diff --git a/skill/SKILL.md b/skill/SKILL.md index 5ee4bb24..547c48f1 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -59,7 +59,7 @@ launchはモデルの非対話実測、同じroomの既存席の撤去、席別 CodexとGrokの席設定・認証は`.team/seats/`へ分離する。Codexにはroomのstdio MCPと列挙したenv、Grokにはroom以外のproject MCPを無効にした席configを渡す。利用者の共有configを書き換えない。 -5. 親は `scripts/parent-join.sh [name] [model] [effort] [harness]` で現在のセッションを登録する。実測できない親のeffortを推測しない。下の「親のoperating notes」に従って、現在の呼出し元へ親宛DM番犬を1世代だけ接続する。`EAR_PROBE_SENT`のnonceを自分の監視イベントとして受信するまで、親の着卓完了としない。 +5. 親は`peertable connect --target claude|codex|grok|cursor`で親MCP/hookを接続し、現在の会話で`parent_join(project,name,model?,effort?,mission?)`を呼ぶ。本人性は実MCPと公式hookで照合する。Cursor/Grokはreceiptにあるnative背景toolの完成済みinputを登録する。耳疎通が`verified`になり、現在runtimeが`armed`になるまで着卓完了としない。 6. membersの全席、最初のclaim、公開Web UIを確認する。Lattice併用ならclaimが工程正本のactiveにも反映されていることを確認する。kickoffは `node scripts/kickoff-gate.mjs --seq --seats ` が`active`を返すまで成立としない。freshな実効状態、delivered receipt、席の`[引受]`発言の3点を使う。 ## 配達と観測 @@ -176,26 +176,14 @@ witness をどう生成するかは**対象 project 側の作法に従う**(La ## 親の operating notes(このセッションの振る舞い) - **親の権限境界(最初に読む・オーナー裁定 2026-08-22)**: 円卓は対等メンバーの自律で回り、各ToDoのクローズは監査担当が行う。親は裁定者ではない——親が卓上で工程手順・着地方法・完了可否を裁定しない。親がやってよいのは、オーナー窓口・環境修理(ブリッジ・CLI・席の器)・campaign 終端の最終監査だけ。実装の代行も裁定の差し込みも、席の正典(roles/member.md)と衝突する「親のバグ」として扱う(実被弾 2026-08-22: Grok 親の実装代行と Fable 親の着地裁定が、監査担当の正規クローズと二重に衝突した) -- 親は MCP を後付けできないため room へは HTTP API 直で参加する: - - 登録: `curl -X POST $URL/api/$ROOM/members -H "X-Peertable-Token: $TOKEN" -d '{"name":"bell"}'` - - 発言: `PEERTABLE_URL=$URL PEERTABLE_ROOM=$ROOM node skill/scripts/post-message.mjs bell <宛先> '<本文>'`(token は env `PEERTABLE_POST_TOKEN`)。script が送信と受領seqの確認まで行い、未達は非ゼロで落ちる——印字だけをPOST成功と誤読して14時間未達になった実被弾(2026-08-26)の根治。curl へのパイプは不要。JSON組み立てだけが要る内部scriptは `--build-only`。Windows の `python3 -c json.dumps` は stdout が cp932 になり日本語本文が部屋へ壊れて保存される。複数人は`to`へ名前の配列(JSON) - - 観測: **bell宛DM番犬**(下記)。素の SSE 全量 Monitor は張らない -- **親宛DM番犬の仕様**(決定76): room追従は`parent-watch.mjs`一つが所有する。`parent-join.sh`が - `.team/parent-watch.json`をprimeし、room SSE・heartbeat・再接続catch-up・`to`/`to_names`判定・ - 永続cursorをscript内で処理する。stdoutの`peertable.parent-watch-event.v1`はDM本文そのもの。 - - **Claude**はpersistent Monitorで`node scripts/parent-watch.mjs <親名> --follow`を1回だけ実行し、 - 出力を親へ通知した後も同じMonitorで待機を続ける。**Codex**はyieldしたbackground tool taskで1秒ごとに - `scripts/codex-parent-watch.sh <親名>`を都度実行し、空でないstdoutだけを`notify`して - `yield_control`する。このscriptは一度HTTP catch-upして即終了し、Node processや端末sessionを常駐させない。 - 通常席用wakeup-bridge、tmux、`codex exec resume`を親へ流用しない。**Grok**はpersistent - Monitorで同じ`--follow`を1回だけ実行する。Grok親をwakeup-bridgeの対象にしない - - 親以外宛・ping・親自身の発言は捨てる。watcher不在中のDMは永続cursorから次回起動時にcatch-upする - - **停滞警報**: 番犬は「着手可能・着手中の工程があるのに作業中(busy)の席が1つも無い」状態が - 3分(`PEERTABLE_STALL_ALARM_MS`)続くと `parent_table_stalled` を1回出す(状態が変わるまで再警報しない)。 - 親はこれを受けたら席の状態一覧を実観測し、止まっている原因(brief不達・承認待ち・hold)を特定して動かす - - **世代は常に1匹**。Claudeは旧MonitorをTaskStop、Codexは旧background taskを停止してから張り替える。 - Grokも旧Monitorを止めてから張り替える。`watch_error`は親へ通知し、沈黙死させない - - `to: "all"`は5類型(claim宣言/完了・クローズ通知+着手可の統合1通/共有リソース占有・解放/全席の前提を変える環境事実/親の進行権能)だけとし、判定は「知らない席は次の行動を間違えるか」の一問で行う(2026-08-25 オーナー裁定。正本はtemplates/member.md)。それ以外は宛先DM、進捗報告・了解は投稿不要。同時のto:allは1通へ統合(claimは独立のまま)。ターン終了時の次の行動は自分宛DM +- 発言は`PEERTABLE_URL=$URL PEERTABLE_ROOM=$ROOM node skill/scripts/post-message.mjs <親名> <宛先> '<本文>'`を使う。tokenはenvまたはcredential fileから渡す。UTF-8のJSON生成を製品に任せ、Windowsのcp932出力や手組みJSONへ置き換えない。複数人宛は名前の配列。失敗時は非ゼロであり、印字だけを成功としない。 +- 親の受信登録はユーザー領域のPeertable専用MCPと公式hookを使う。既存会話で読み込めなければ`PARENT_RESTART_REQUIRED`を返す。発言は`post-message.mjs`の既存HTTP入口を使い、送信と受領seqを確認する。room保存と配送成立は別に読む。 +- **親配送**: 共通watchがroom HTTP/SSE・永続cursorを所有し、親宛DM・親を含む複数人宛・all全件の原文をspoolへ保存する。Lattice件数のquiet観測、取得エラー、snapshot、耳疎通probe、3分の停滞警報と既存頻度を保つ。 + - ClaudeはPostToolUse/Stopの公式asyncRewakeで同じsessionを起こす。sessionは1slot、有限leaseの期限controlで同じjoinを更新する。 + - Codexは実callerの公式queueと同期PostToolUse/Stopを使う。他製品のqueueや承認を変更しない。 + - Cursorは公式hookとnative背景Shellがclaimを共有する。Grokはnative背景commandの完了と公開結果回収を使う。両者は`wait_process.native_tool`の完成済みinputを実行し、公式hookの登録確認後にarmedとなる。 + - Cursor/Grokは外部作業が不要でも次の受信維持toolを登録する。受信維持まで省略したturnはrearm_pendingの失敗。timeoutは本文成功に数えず、新receiptで再武装する。生きたslotがある間は別taskを増やさない。 + - 長文は`parent_read`の継続tokenで全量を回収し、最後の出力完了までackしない。unknownは原文と受付証拠を保持し、自動再送しない。親配送は通常席bridgeの対象外。 - **model / effort変更依頼**: 本人の自然文DMを親が判断し、確定したtargetだけを上記6.7のscriptへ渡す。本人に定型文や完全一致の再送を求めず、親が本人の代わりに依頼文を投稿しない - 親の権能は進行・督促・オーナーとの接点だけ。作業者や監査担当を代行しない - **作業者は自ら必要な試験と自己監査を行い、工程を次に進めてよい水準まで完成させる。** 完成したら証跡へ記したものと同じ最終的な試験内容と試験結果を監査担当へ渡し、自分では工程をクローズしない @@ -206,9 +194,8 @@ witness をどう生成するかは**対象 project 側の作法に従う**(La - **親の再着卓**(context が要約された/セッションが替わった時。決定51 のメンバー版に対応する親版。2026-08-08 実測): 卓は生きたまま親だけが記憶を失う局面なので、**復帰は記憶ではなく正本から取り直す**。順に: 1. **room ログを読む**——`curl -s "$URL/api/$ROOM/messages?since=<最後に読んだ seq>"`。`since` を持っていなければ 0 から。**会話が卓の正本**なので、まずここで現在地(誰が何を claim し、どこまで done か)を作る 2. **工程正本で照合する**——`lattice todo status --json`(Lattice 併用)。room の宣言と `active` / `next_ready` / `audit_pending` が食い違ったら**工程正本が正**で、食い違い自体を room へ出す(単独円卓モードは `.team/tasks.md` と room ログの突き合わせ) - 3. **member 登録は残っている**ので `parent-join.sh` を再実行しない。`curl -s $URL/api/$ROOM/members` で自分の名前を確認するだけでよい(実測: 親の登録はセッションを跨いで残る)。**再実行しても `<名前> が参加した` は流れない**——`POST /members` は本当に新規追加の時だけ本人宛のsystem発言を出す - 4. **番犬を張り直す**——ClaudeとGrokは`--follow`を1回起動する。Codexは1秒ごとの`codex-parent-watch.sh` loopをbackground taskで起動する。生きた旧世代が残っていれば先に止める(世代は常に1匹)。永続cursorが不在時間のDMを回収する。**張り直したら耳の疎通を実測する**——自分宛のprobe DM(差出人は自分以外なら何でもよい)を1通投稿し、それが監視イベントとして届くのを確認するまで再着卓完了と言わない - - **再着卓の契機は番犬taskの終了通知または`watch_error`**。親の側には「途絶した」と教える別経路が無いので、届いたら再着卓の手順に入る + 3. **現在の会話でparent_joinを呼ぶ**。同じ実会話は同じendpoint/cursorを復旧する。別会話は新世代を登録し、旧本文・unknownを引き継がない。member台帳の表示だけで受信成立としない + 4. **必要なnative背景toolを登録する**。Cursor/Grokはreceiptの完成済みinputを使う。Claude/Codexは公式受信口を使う。耳疎通verifiedと現在runtime armedを診断する。親登録だけを閉じる時はparent_leave、円卓の解散はteardown - **順序の要点は「room と工程正本を読み終えるまで発言しない」**。読む前に喋ると、自分が行き違いを作る側になる(実例あり) - **やらないこと**: 復帰の挨拶で席を起こさない。作業の再確認を席へ聞いて回らない——**現在地は上の1〜2で取れる**ので、聞くのは席の時間を奪うだけである - **宛先の規律**: `to: "all"`は5類型(claim宣言/完了・クローズ通知+着手可の統合1通/共有リソース占有・解放/全席の前提を変える環境事実/親の進行権能)だけとし、判定は「知らない席は次の行動を間違えるか」の一問で行う(2026-08-25 オーナー裁定。正本はtemplates/member.md)。それ以外は宛先DM、進捗報告・了解は投稿不要。同時のto:allは1通へ統合(claimは独立のまま)。ターン終了時の次の行動は自分宛DM。別の通知機構は置かない diff --git a/skill/scripts/cli.mjs b/skill/scripts/cli.mjs index d4e0861b..3814ba02 100644 --- a/skill/scripts/cli.mjs +++ b/skill/scripts/cli.mjs @@ -7,6 +7,7 @@ import { leaveSeat } from './leave-seat.mjs' import { diagnoseProject } from './doctor.mjs' import { teardownProject } from './teardown.mjs' import { changeSeat } from './change-seat.mjs' +import { connectCommand } from './parent-connect.mjs' function projectOptions(args, { seat = false } = {}) { const options = { project: args[0], phases: [] } @@ -33,7 +34,11 @@ function projectOptions(args, { seat = false } = {}) { const [command, ...args] = process.argv.slice(2) try { - if (command === 'install') { + if (command === 'connect') { + const result = await connectCommand(args) + console.log(JSON.stringify(result)) + if (result.status === 'failed') process.exitCode = 1 + } else if (command === 'install') { const result = skillCommand(args) console.log(JSON.stringify(result)) if (args.includes('--check') && result.status !== 'ready') process.exitCode = 1 @@ -43,7 +48,9 @@ try { console.log(JSON.stringify(result)) if (result.status !== 'ready') process.exitCode = 1 } else if (command === 'setup' || command === 'resume') { - console.log(JSON.stringify(await (command === 'setup' ? setupProject : resumeProject)(projectOptions(args)))) + const result = await (command === 'setup' ? setupProject : resumeProject)(projectOptions(args)) + console.log(JSON.stringify(result)) + if (result.status !== 'ready') process.exitCode = 1 } else if (command === 'launch') { console.log(JSON.stringify(await launchSeat(projectOptions(args, { seat: true })))) } else if (command === 'change') { @@ -56,6 +63,7 @@ try { } else if (!command || ['--help', '-h', 'help'].includes(command)) { console.log(`Peertable peertable install [--target claude|codex|grok|cursor] [--json] + peertable connect [--target claude|codex|grok|cursor] [--remove] [--json] peertable diagnostics [--target claude|codex|grok|cursor] [--json] peertable diagnostics [--repair] peertable setup --room --url --tasks diff --git a/skill/scripts/doctor.mjs b/skill/scripts/doctor.mjs index a1de8615..94c832c7 100644 --- a/skill/scripts/doctor.mjs +++ b/skill/scripts/doctor.mjs @@ -1,3 +1,4 @@ +import { isParentMember } from '../../room/parent-kind.mjs' // 診断はPeertableの記録とAitermの公開観測を照合する。repairはbridgeだけを直す。 import { existsSync } from 'node:fs' import { execFileSync } from 'node:child_process' @@ -9,6 +10,7 @@ import { bridgeRecordLive } from './bridge-record-live.mjs' import { ensureBridge } from './ensure-project-runtime.mjs' import { projectPath, readSetup, readJson } from './project-scaffold.mjs' import { resolveLatticeInvocation } from './seat-usage.mjs' +import { parentRuntimeStatus } from './parent-runtime.mjs' export async function diagnoseProject(options) { const project = projectPath(options.project) @@ -23,9 +25,14 @@ export async function diagnoseProject(options) { try { await check('room到達', () => api.request('summary')) const listed = await check('member台帳', async () => ({ members: await api.members() })) + await check('親receiver', async () => { + const parent = await parentRuntimeStatus(project, { restart: options.repair === true }) + if (parent.status !== 'ready') throw Object.assign(new Error(JSON.stringify(parent)), { code: 'PARENT_RUNTIME_NOT_READY' }) + return { parent } + }) await check('Aitermと預け仕事の公開観測', async () => ({ sessions: (await aiterm.sessions(['PEERTABLE_MEMBER', 'PEERTABLE_ROOM'])).length })) for (const member of listed?.members ?? []) { - if (member.delivery?.kind === 'parent_watch' || !seatSessionId(member)) continue + if (isParentMember(member) || !seatSessionId(member)) continue await check(`席 ${member.name}`, async () => { if (!member.roles?.length || !member.model || !member.pid || !member.started_identity || !member.argv_digest) throw new Error('台帳の素性・本人性が不完全です') const observed = await aiterm.observe(seatSessionId(member)) diff --git a/skill/scripts/ensure-all-bridges.mjs b/skill/scripts/ensure-all-bridges.mjs index 8345e9d6..bc01f4f6 100644 --- a/skill/scripts/ensure-all-bridges.mjs +++ b/skill/scripts/ensure-all-bridges.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { isParentMember } from '../../room/parent-kind.mjs' // 既存のsetup記録があるprojectだけを巡回する。npm導入からは呼ばない。 import { existsSync, readdirSync, statSync, writeFileSync, appendFileSync } from 'node:fs' import { homedir } from 'node:os' @@ -23,7 +24,7 @@ try { const state = readSetup(project) await ensureProjectRuntime(project, { aiterm }) const members = await new RoomApi(state).members() - const missing = members.filter(member => member.delivery?.kind !== 'parent_watch' && (member.harness ?? member.vendor) + const missing = members.filter(member => !isParentMember(member) && (member.harness ?? member.vendor) && !findSeatSession(member, sessions, state.room)) if (!missing.length) continue const lock = join(project, '.team', 'seat-revive.lock') diff --git a/skill/scripts/ensure-project-runtime.mjs b/skill/scripts/ensure-project-runtime.mjs index e2fb2437..77753b25 100644 --- a/skill/scripts/ensure-project-runtime.mjs +++ b/skill/scripts/ensure-project-runtime.mjs @@ -10,6 +10,7 @@ import { bridgeRecordLive } from './bridge-record-live.mjs' import { packageRoot } from './install-skill.mjs' import { projectPath, readSetup, readJson, runScript, fail } from './project-scaffold.mjs' import { runtimeLaunchCommand } from './runtime-launch-command.mjs' +import { parentRuntimeStatus } from './parent-runtime.mjs' export const bridgeKinds = Object.freeze(['alarm', 'seat-status', 'wakeup']) export const bridgeSession = (project, room, kind) => `peertable-${kind}-${room}-${createHash('sha256').update(project).digest('hex').slice(0, 8)}` @@ -73,7 +74,8 @@ export async function ensureProjectRuntime(project, options = {}) { try { const bridges = [] for (const kind of bridgeKinds) bridges.push(await ensureBridge(project, kind, { ...options, aiterm })) - return { schema: 'peertable.runtime-result.v1', status: 'ready', bridges } + const parent = await parentRuntimeStatus(project, { restart: true }) + return { schema: 'peertable.runtime-result.v1', status: parent.status, bridges, parent } } finally { if (!options.aiterm) await aiterm.close() } diff --git a/skill/scripts/parent-caller.mjs b/skill/scripts/parent-caller.mjs new file mode 100644 index 00000000..8063c43d --- /dev/null +++ b/skill/scripts/parent-caller.mjs @@ -0,0 +1,106 @@ +// 公式hookとMCP要求を一度だけ消費する相関。時刻やcwdでは本人を選ばない。 +import { existsSync, renameSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { realpathSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import { homedir } from 'node:os' +import { parentHome, atomicJson, readJson, failure, sameProcess, harnessProcess } from './parent-platform.mjs' +import { digest, ParentSpool } from './parent-delivery.mjs' + +const UUID = /^[0-9a-f-]{36}$/u +export function clientHarness(client) { + const name = client?.name ?? '' + if (name === 'codex-mcp-client') return 'codex' + if (name === 'claude-code') return 'claude' + if (name === 'Cursor' || name === 'cursor-vscode' || name.startsWith('cursor-vscode ')) return 'cursor' + if (name === 'grok-shell-peertable_parent') return 'grok' + throw failure('PARENT_CLIENT_UNSUPPORTED', `未確認のMCP client: ${name}`) +} +export function assertClaudeParent(event) { + if (event.agent_id) throw failure('CLAUDE_PARENT_SUBAGENT_UNSUPPORTED', 'agent_id付きのClaude会話は親のasyncRewake受信に対応しません') +} +export function hookContext(harness, event) { + if (harness === 'claude') assertClaudeParent(event) + const conversation = harness === 'grok' ? event.sessionId : harness === 'cursor' ? event.conversation_id : event.session_id + const use = harness === 'grok' ? event.toolUseId : event.tool_use_id + const fullName = harness === 'grok' ? event.toolName : event.tool_name + const name = /parent_(join|read|leave)$/u.exec(fullName ?? '')?.[0] + const envelope = harness === 'grok' ? event.toolInput : event.tool_input + const input = harness === 'grok' ? envelope?.tool_input : envelope + if (!name) return null + if (!conversation || !use || !input || typeof input !== 'object' || (harness === 'grok' && envelope?.tool_name !== fullName)) throw failure('PARENT_CALLER_UNBOUND') + const owner = harnessProcess(harness) + if (!owner) throw failure('PARENT_HARNESS_IDENTITY_UNBOUND') + const id = randomUUID() + const context = { id, harness, conversation, use, name, agent_id: harness === 'claude' ? event.agent_id ?? null : null, input_digest: digest(input), owner, created_at: Date.now(), call_generation: randomUUID() } + const key = harness === 'claude' ? digest(['claude', use]) : id + atomicJson(join(parentHome(), 'contexts', `${key}.json`), context) + return { context, updated: harness === 'grok' ? { ...envelope, tool_input: { ...input, hook_context_id: id } } : { ...input, hook_context_id: id } } +} +export function consumeCaller(harness, request, requestId) { + const args = request.params.arguments ?? {} + if (harness === 'codex') { + const conversation = request.params._meta?.threadId + if (typeof conversation !== 'string' || !conversation) throw failure('PARENT_CALLER_UNBOUND') + const owner = harnessProcess(harness) + if (!owner) throw failure('PARENT_HARNESS_IDENTITY_UNBOUND') + const codex_home = realpathSync(process.env.CODEX_HOME ?? join(homedir(), '.codex')) + if (typeof requestId !== 'string' && typeof requestId !== 'number') throw failure('PARENT_REQUEST_ID_UNBOUND') + return { harness, conversation, owner, codex_home, use: String(requestId), name: request.params.name } + } + const id = harness === 'claude' ? request.params._meta?.['claudecode/toolUseId'] : args.hook_context_id + if (typeof id !== 'string' || !id || (harness !== 'claude' && !UUID.test(id))) throw failure('PARENT_CALLER_UNBOUND') + const key = harness === 'claude' ? digest(['claude', id]) : id + const file = join(parentHome(), 'contexts', `${key}.json`) + const consumed = `${file}.consumed-${randomUUID()}` + try { renameSync(file, consumed) } catch (error) { if (error.code === 'ENOENT') throw failure('PARENT_CALLER_UNBOUND'); throw error } + const context = readJson(consumed) + if (harness === 'claude') assertClaudeParent(context) + const original = { ...args }; delete original.hook_context_id + if (context.harness !== harness || context.name !== request.params.name || context.input_digest !== digest(original) || !sameProcess(context.owner) || Date.now() - context.created_at > 30000) throw failure('PARENT_CALLER_MISMATCH') + return context +} +export function registerEndpoint(spool) { + atomicJson(join(parentHome(), 'endpoints', `${spool.id}.json`), { endpoint_id: spool.id, project: realpathSync(spool.project) }) +} +export function endpointById(id) { + if (!UUID.test(id ?? '')) throw failure('PARENT_ENDPOINT_INVALID') + const file = join(parentHome(), 'endpoints', `${id}.json`) + if (!existsSync(file)) throw failure('PARENT_ENDPOINT_NOT_FOUND') + const index = readJson(file) + return new ParentSpool(index.project, id) +} +export function endpointsFor({ project = null, caller = null } = {}) { + const dir = join(parentHome(), 'endpoints') + if (!existsSync(dir)) return [] + return readdirSync(dir).filter(name => /^[0-9a-f-]{36}\.json$/u.test(name)).map(name => endpointById(name.slice(0, -5))) + .filter(spool => (!project || realpathSync(project) === realpathSync(spool.project)) && (!caller || (spool.read().caller.harness === caller.harness && spool.read().caller.conversation === caller.conversation))) +} +export function verifyCaller(spool, caller) { + const binding = spool.read().caller + if (binding.harness !== caller.harness || binding.conversation !== caller.conversation || binding.owner.pid !== caller.owner.pid || binding.owner.started !== caller.owner.started) throw failure('PARENT_CALLER_MISMATCH') +} +export function saveJoinResult(caller, result) { + atomicJson(join(parentHome(), 'join-results', `${digest([caller.harness, caller.conversation, caller.use])}.json`), { caller, result }) +} +export function verifyJoinHook(harness, event, owner) { + const fullName = harness === 'grok' ? event.toolName : event.tool_name + if (!/parent_join$/u.test(fullName ?? '')) return null + if (harness === 'cursor' && event.hook_event_name === 'afterMCPExecution' && event.mcp_server_name !== 'peertable_parent') return null + const conversation = harness === 'grok' ? event.sessionId : harness === 'cursor' ? event.conversation_id : event.session_id + const use = harness === 'grok' ? event.toolUseId : event.tool_use_id + if (!conversation || !use) throw failure('PARENT_CALLER_UNBOUND') + const file = join(parentHome(), 'join-results', `${digest([harness, conversation, use])}.json`) + if (!existsSync(file)) throw failure('PARENT_JOIN_RESULT_UNBOUND') + const saved = readJson(file) + const find = value => { + if (typeof value === 'string') { try { return find(JSON.parse(value)) } catch { return null } } + if (!value || typeof value !== 'object') return null + if (value.schema === 'peertable.parent-join-result.v1') return value + for (const child of Object.values(value)) { const found = find(child); if (found) return found } + return null + } + const result = find(harness === 'grok' ? event.toolResult : event.tool_output ?? event.tool_response ?? event.result_json) + if (!result || result.endpoint_id !== saved.result.endpoint_id || owner.pid !== saved.caller.owner.pid || owner.started !== saved.caller.owner.started) throw failure('PARENT_JOIN_RESULT_MISMATCH') + return result +} diff --git a/skill/scripts/parent-connect.mjs b/skill/scripts/parent-connect.mjs new file mode 100644 index 00000000..48cdf7f3 --- /dev/null +++ b/skill/scripts/parent-connect.mjs @@ -0,0 +1,202 @@ +// user領域のMCP/hookはPeertableのentryだけを所有する。外部設定を読む前後で照合する。 +import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { homedir } from 'node:os' +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { parentHome, atomicJson, readJson, hookCommand, failure } from './parent-platform.mjs' +import { digest } from './parent-delivery.mjs' +import { codexConnection } from './parent-receivers/codex.mjs' + +export const parentServerName = 'peertable_parent' +const hookEntry = fileURLToPath(new URL('./parent-hook.mjs', import.meta.url)) +const clientEntry = fileURLToPath(new URL('../../room/client.mjs', import.meta.url)) +export const parentRegistration = () => ({ command: process.execPath, args: [clientEntry, 'parent'] }) +const pathFor = target => { + const home = homedir() + if (target === 'claude') return { mcp: join(process.env.CLAUDE_CONFIG_DIR ?? home, '.claude.json'), hooks: join(process.env.CLAUDE_CONFIG_DIR ?? join(home, '.claude'), 'settings.json') } + if (target === 'cursor') return { mcp: join(process.env.CURSOR_HOME ?? join(home, '.cursor'), 'mcp.json'), hooks: join(process.env.CURSOR_HOME ?? join(home, '.cursor'), 'hooks.json') } + if (target === 'codex') return { mcp: join(process.env.CODEX_HOME ?? join(home, '.codex'), 'config.toml'), hooks: join(process.env.CODEX_HOME ?? join(home, '.codex'), 'hooks.json') } + return { mcp: join(home, '.grok', 'config.toml'), hooks: join(home, '.grok', 'hooks', 'peertable-parent.json') } +} +export function hookEntries(target) { + const command = hookCommand(process.execPath, [hookEntry, target]) + const native = target === 'claude' ? { type: 'command', command: process.execPath, args: [hookEntry, target] } : { type: 'command', command } + if (target === 'cursor') return Object.fromEntries(['preToolUse', 'postToolUse', 'afterMCPExecution', 'sessionEnd'].map(name => [name, [{ command }]])) + if (target === 'claude') return { + PreToolUse: [{ matcher: '^mcp__peertable_parent__parent_(join|read|leave)$', hooks: [{ ...native, timeout: 20 }] }], + PostToolUse: [{ matcher: '^mcp__peertable_parent__parent_join$', hooks: [{ ...native, asyncRewake: true, timeout: 86400 }] }], + Stop: [{ hooks: [{ ...native, asyncRewake: true, timeout: 86400 }] }], SessionEnd: [{ hooks: [{ ...native, timeout: 20 }] }], + } + if (target === 'codex') return { PostToolUse: [{ matcher: '.*', hooks: [{ ...native, timeout: 20, additionalContextLimit: 0 }] }], Stop: [{ hooks: [{ ...native, timeout: 20 }] }] } + return Object.fromEntries(['PreToolUse', 'PostToolUse', 'SessionEnd'].map(name => [name, [{ hooks: [{ ...native, timeout: 20 }] }]])) +} +const commandIdentity = entry => JSON.stringify([entry.command, entry.args ?? []]) +export function mergeOwnedHooks(current, additions, ownedCommands) { + const next = { ...current, hooks: { ...(current.hooks ?? {}) } } + for (const name of new Set([...Object.keys(next.hooks), ...Object.keys(additions)])) { + if (!Array.isArray(next.hooks[name] ?? [])) throw failure('PARENT_HOOK_CONFIG_INVALID') + let inserted = false + const groups = [] + for (const group of next.hooks[name] ?? []) { + const hooks = group.hooks ?? [group] + const owns = hooks.some(hook => ownedCommands.includes(commandIdentity(hook))) + if (!owns) { groups.push(group); continue } + const foreign = hooks.filter(hook => !ownedCommands.includes(commandIdentity(hook))) + if (foreign.length) groups.push({ ...group, hooks: foreign }) + if (!inserted && additions[name]) { groups.push(...additions[name]); inserted = true } + } + if (!inserted && additions[name]) groups.push(...additions[name]) + next.hooks[name] = groups + } + return next +} +// TOMLのliteral/basic quoted keyを解析して自分のtableだけを置換する。 +// 他のtableの本文・順序・コメントはbyte単位で残す。 +export function tomlHeaderKeys(line) { + const match = /^\s*\[([^\[\]]+)\]\s*(?:#.*)?$/u.exec(line) + if (!match) return null + const raw = match[1], keys = [] + let index = 0 + while (index < raw.length) { + while (/\s/u.test(raw[index] ?? '') && index < raw.length) index++ + const quote = raw[index] + if (quote === '"' || quote === "'") { + const start = index++ + while (index < raw.length && (raw[index] !== quote || (quote === '"' && raw[index - 1] === '\\'))) index++ + if (index >= raw.length) throw failure('PARENT_TOML_HEADER_INVALID') + const token = raw.slice(start, ++index) + keys.push(quote === '"' ? JSON.parse(token) : token.slice(1, -1)) + } else { + const token = /^[A-Za-z0-9_-]+/u.exec(raw.slice(index))?.[0] + if (!token) throw failure('PARENT_TOML_HEADER_INVALID') + keys.push(token); index += token.length + } + while (/\s/u.test(raw[index] ?? '') && index < raw.length) index++ + if (index < raw.length && raw[index++] !== '.') throw failure('PARENT_TOML_HEADER_INVALID') + } + return keys +} +export function ownedTomlBlock(text, rootKeys = ['mcp_servers', parentServerName]) { + const lines = text.split(/(?<=\n)/u), indexes = [] + for (let index = 0; index < lines.length; index++) { + const keys = tomlHeaderKeys(lines[index].trimEnd()) + if (keys && rootKeys.every((key, part) => keys[part] === key)) { + let end = index + 1 + while (end < lines.length && !/^\s*\[/u.test(lines[end])) end++ + indexes.push({ start: index, end }) + } + } + return { lines, indexes, block: indexes.map(({ start, end }) => lines.slice(start, end).join('')).join('') } +} +export function replaceOwnedToml(text, replacement, expected, rootKeys) { + const found = ownedTomlBlock(text, rootKeys) + if (found.block && (expected === undefined || digest(found.block) !== expected)) throw failure('PARENT_CONFIG_OWNERSHIP_CONFLICT') + const starts = new Map(found.indexes.map(range => [range.start, range])) + let out = '', inserted = false + for (let index = 0; index < found.lines.length;) { + const range = starts.get(index) + if (range) { if (!inserted) { out += replacement; inserted = true }; index = range.end } + else out += found.lines[index++] + } + return inserted ? out : `${out}${out && !out.endsWith('\n') ? '\n' : ''}${replacement}` +} +function backup(files) { + const present = files.filter(existsSync) + if (!present.length) return null + const dir = join(parentHome(), 'backups'); mkdirSync(dir, { recursive: true, mode: 0o700 }) + const file = join(dir, `${Date.now()}-${process.pid}.tar`) + // tarは設定fileだけを控える。認証や会話のHOME全体はコピーしない。 + execFileSync(process.platform === 'win32' ? 'tar.exe' : 'tar', ['-cf', file, ...present], { stdio: ['ignore', 'pipe', 'pipe'] }) + return file +} +function writeText(file, text) { mkdirSync(dirname(file), { recursive: true }); const temp = `${file}.${process.pid}.tmp`; writeFileSync(temp, text, { mode: 0o600 }); renameSync(temp, file); if (readFileSync(file, 'utf8') !== text) throw failure('PARENT_CONFIG_READBACK_FAILED') } + +export async function connectParent(target, { remove = false } = {}) { + if (!['claude', 'codex', 'grok', 'cursor'].includes(target)) throw failure('PARENT_TARGET_INVALID') + const paths = pathFor(target), recordPath = join(parentHome(), 'connections', `${target}.json`) + const previous = existsSync(recordPath) ? readJson(recordPath) : null + const registration = parentRegistration(), additions = remove ? {} : hookEntries(target) + const commands = Object.values(hookEntries(target)).flatMap(groups => groups.flatMap(group => group.hooks ?? [group])).map(commandIdentity) + const ownedCommands = [...new Set([...(previous?.commands ?? []), ...commands])] + const currentHooks = existsSync(paths.hooks) ? readJson(paths.hooks) : {} + const currentOwned = Object.fromEntries(Object.entries(currentHooks.hooks ?? {}).map(([key, groups]) => [key, groups.filter(group => (group.hooks ?? [group]).some(hook => ownedCommands.includes(commandIdentity(hook))))]).filter(([, groups]) => groups.length)) + if (previous && digest(currentOwned) !== previous.hooks_digest) throw failure('PARENT_CONFIG_OWNERSHIP_CONFLICT', 'Peertableのhookに未監査の編集があります') + const nextHooks = mergeOwnedHooks(currentHooks, additions, ownedCommands) + if (target === 'cursor') nextHooks.version = currentHooks.version ?? 1 + const archive = backup([paths.mcp, paths.hooks]) + if (target === 'claude' || target === 'cursor') { + const current = existsSync(paths.mcp) ? readJson(paths.mcp) : {}, entry = current.mcpServers?.[parentServerName] + if (entry && digest(entry) !== previous?.mcp_digest && digest(entry) !== digest(registration) && digest(entry) !== digest({ type: 'stdio', ...registration })) throw failure('PARENT_CONFIG_OWNERSHIP_CONFLICT') + const next = { ...current, mcpServers: { ...(current.mcpServers ?? {}) } } + if (remove) delete next.mcpServers[parentServerName] + else next.mcpServers[parentServerName] = target === 'claude' ? { type: 'stdio', ...registration } : registration + atomicJson(paths.mcp, next) + if (digest(readJson(paths.mcp)) !== digest(next)) throw failure('PARENT_CONFIG_READBACK_FAILED') + } else if (target === 'grok') { + const text = existsSync(paths.mcp) ? readFileSync(paths.mcp, 'utf8') : '' + const block = remove ? '' : `\n[mcp_servers.${parentServerName}]\ncommand = ${JSON.stringify(registration.command)}\nargs = ${JSON.stringify(registration.args)}\n` + writeText(paths.mcp, replaceOwnedToml(text, block, previous?.mcp_digest)) + } else { + mkdirSync(dirname(paths.mcp), { recursive: true }) + const client = await codexConnection(dirname(paths.mcp)) + try { + const current = await client.request('config/read', { includeLayers: true }) + const entry = current.config?.mcp_servers?.[parentServerName] + if (entry && previous?.mcp_digest !== digest(entry) && digest(entry) !== digest(registration)) throw failure('PARENT_CONFIG_OWNERSHIP_CONFLICT') + if (remove) { + // 自分のMCP tableをTOML構造で解決して解除。literal quoted headerにも対応する。 + let text = readFileSync(paths.mcp, 'utf8'), block = ownedTomlBlock(text).block + text = replaceOwnedToml(text, '', digest(block)) + for (const hook of previous?.trust ?? []) { + const keys = ['hooks', 'state', hook.key] + const owned = ownedTomlBlock(text, keys).block + if (owned) text = replaceOwnedToml(text, '', digest(owned), keys) + } + writeText(paths.mcp, text) + } else await client.request('config/batchWrite', { filePath: paths.mcp, edits: [{ keyPath: `mcp_servers.${parentServerName}`, value: registration, mergeStrategy: 'replace' }] }) + } finally { await client.close() } + } + atomicJson(paths.hooks, nextHooks) + if (digest(readJson(paths.hooks)) !== digest(nextHooks)) throw failure('PARENT_CONFIG_READBACK_FAILED') + let trust = null + if (target === 'codex' && !remove) { + const client = await codexConnection(dirname(paths.mcp)) + try { + const list = () => client.request('hooks/list', { cwds: [dirname(paths.mcp)] }) + const own = (await list()).data.flatMap(group => group.hooks ?? []).filter(hook => hook.sourcePath === paths.hooks && ownedCommands.includes(commandIdentity({ command: hook.command }))) + if (own.length !== 2) throw failure('PARENT_CODEX_HOOK_NOT_DISCOVERED') + await client.request('config/batchWrite', { filePath: paths.mcp, edits: own.flatMap(hook => [ + { keyPath: `hooks.state.${JSON.stringify(hook.key)}.trusted_hash`, value: hook.currentHash, mergeStrategy: 'replace' }, + { keyPath: `hooks.state.${JSON.stringify(hook.key)}.enabled`, value: true, mergeStrategy: 'replace' }, + ]) }) + trust = (await list()).data.flatMap(group => group.hooks ?? []).filter(hook => own.some(entry => entry.key === hook.key)) + if (trust.some(hook => !hook.enabled || hook.trustStatus !== 'trusted')) throw failure('PARENT_CODEX_HOOK_UNTRUSTED') + } finally { await client.close() } + } + const savedOwned = Object.fromEntries(Object.entries(nextHooks.hooks ?? {}).map(([key, groups]) => [key, groups.filter(group => (group.hooks ?? [group]).some(hook => ownedCommands.includes(commandIdentity(hook))))]).filter(([, groups]) => groups.length)) + let mcpDigest = target === 'grok' ? digest(ownedTomlBlock(readFileSync(paths.mcp, 'utf8')).block) : target === 'claude' ? digest({ type: 'stdio', ...registration }) : digest(registration) + if (target === 'codex' && !remove) { + const client = await codexConnection(dirname(paths.mcp)) + try { + const entry = (await client.request('config/read', { includeLayers: true })).config?.mcp_servers?.[parentServerName] + if (entry?.command !== registration.command || digest(entry.args) !== digest(registration.args)) throw failure('PARENT_CONFIG_READBACK_FAILED') + mcpDigest = digest(entry) + } finally { await client.close() } + } + const result = { schema: 'peertable.parent-connect.v1', target, status: remove ? 'removed' : 'registered', runtime_status: remove ? 'stopped' : 'PARENT_RESTART_REQUIRED', paths, commands: ownedCommands, hooks_digest: digest(savedOwned), mcp_digest: mcpDigest, backup: archive, trust, updated_at: new Date().toISOString() } + atomicJson(recordPath, result) + return result +} + +export async function connectCommand(args) { + const index = args.indexOf('--target'), target = index >= 0 ? args[index + 1] : null + if (index >= 0 && !target) throw failure('PARENT_TARGET_INVALID') + const targets = target ? [target] : ['claude', 'codex', 'grok', 'cursor'].filter(name => existsSync(dirname(pathFor(name).hooks))) + const results = [] + for (const name of targets) { + try { results.push(await connectParent(name, { remove: args.includes('--remove') })) } + catch (error) { results.push({ target: name, status: 'failed', error_code: error.code ?? 'PARENT_CONNECT_FAILED', detail: error.message }) } + } + return { schema: 'peertable.parent-connections.v1', status: results.length && results.every(result => result.status !== 'failed') ? 'registered' : 'failed', results } +} diff --git a/skill/scripts/parent-delivery.mjs b/skill/scripts/parent-delivery.mjs new file mode 100644 index 00000000..0fa617dd --- /dev/null +++ b/skill/scripts/parent-delivery.mjs @@ -0,0 +1,297 @@ +// roomを正本とする配送spool。本文保存とcursor、claimとreceiptは同じatomic更新にする。 +import { mkdirSync, rmSync, existsSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { createHash, createHmac, randomUUID, randomBytes, timingSafeEqual } from 'node:crypto' +import { atomicJson, readJson, processIdentity, sameProcess, failure } from './parent-platform.mjs' +import { RoomApi } from './room-api.mjs' + +const pause = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) +const now = () => new Date().toISOString() +const canonical = value => value === null || typeof value !== 'object' ? JSON.stringify(value) + : Array.isArray(value) ? `[${value.map(canonical).join(',')}]` + : `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}` +export const digest = value => createHash('sha256').update(typeof value === 'string' ? value : canonical(value)).digest('hex') +export const PAGE_CHARS = 12000 +export const eventKinds = ['parent_dm', 'parent_room_update', 'parent_lattice_error', 'parent_lattice_update', 'parent_table_stalled', 'parent_watch_snapshot', 'watch_error', 'parent_probe'] + +export function withParentLock(root, fn) { + const owner = processIdentity(process.pid) + if (!owner) throw failure('PARENT_PROCESS_IDENTITY_UNAVAILABLE') + mkdirSync(root, { recursive: true, mode: 0o700 }) + const id = randomUUID(), file = join(root, `${id}.json`), deadline = Date.now() + 10000 + // Bakery排他。stale ownerの共有fileを消さず、自分の番号だけを所有する。 + const participants = () => readdirSync(root).filter(name => /^[0-9a-f-]{36}\.json$/u.test(name)).flatMap(name => { + let entry + try { entry = readJson(join(root, name)) } catch (error) { if (error.code === 'ENOENT') return []; throw error } + if (!sameProcess(entry.owner)) { rmSync(join(root, name), { force: true }); return [] } + return [entry] + }) + const ticket = { id, owner, choosing: true, number: 0 } + atomicJson(file, ticket) + try { + ticket.number = Math.max(0, ...participants().map(entry => entry.number)) + 1 + ticket.choosing = false + atomicJson(file, ticket) + while (participants().some(entry => entry.id !== id && (entry.choosing || entry.number < ticket.number || (entry.number === ticket.number && entry.id < id)))) { + if (Date.now() >= deadline) throw failure('PARENT_SPOOL_LOCK_TIMEOUT') + pause(15) + } + return fn() + } finally { rmSync(file, { force: true }) } +} + +export class ParentSpool { + constructor(project, endpointId) { + if (!/^[0-9a-f-]{36}$/u.test(endpointId)) throw failure('PARENT_ENDPOINT_INVALID') + this.project = project + this.id = endpointId + this.root = join(project, '.team', 'parent-delivery', endpointId) + this.file = join(this.root, 'spool.json') + this.lock = join(this.root, 'transactions') + } + static create(project, binding) { + const spool = new ParentSpool(project, binding.endpoint_id ?? randomUUID()) + mkdirSync(spool.root, { recursive: true, mode: 0o700 }) + if (existsSync(spool.file)) throw failure('PARENT_ENDPOINT_ALREADY_EXISTS') + atomicJson(spool.file, { schema: 'peertable.parent-spool.v1', endpoint_id: spool.id, + generation: randomUUID(), ...binding, state: 'receiving', runtime: 'rearm_pending', created_at: now(), + continuation_key: randomBytes(32).toString('hex'), records: [], quiet_events: [], cursor: binding.start_seq, + watcher: null, waiter: null, source_state: null, migration: null }) + return spool + } + read() { return readJson(this.file) } + transact(fn) { + return withParentLock(this.lock, () => { + const state = this.read() + const result = fn(state) + atomicJson(this.file, state) + return result + }) + } + update(fields) { return this.transact(state => { Object.assign(state, fields); return state }) } + async assertCurrentEndpoint(api = new RoomApi(this.read(), { credential: this.read().credential })) { + const state = this.read() + const member = (await api.members()).find(member => member.name === state.name) + if (member?.delivery?.kind === 'parent_receiver' && member.delivery.endpoint_id === this.id) return + this.transact(saved => { + saved.runtime = 'stopped'; saved.state = 'failed'; saved.error_code = 'PARENT_ENDPOINT_SUPERSEDED' + for (const record of saved.records) if (record.state === 'ready' || record.state === 'waiting') { + record.state = 'failed'; record.error_code = saved.error_code + record.receipt = null + } + }) + throw failure('PARENT_ENDPOINT_SUPERSEDED') + } + async publishHealth() { + const saved = this.read() + const state = saved.runtime === 'armed' && (saved.state !== 'verified' || saved.receipt_error || saved.migration?.status === 'evidence_missing' || saved.records.some(record => ['unknown', 'failed'].includes(record.state))) ? 'failed' : saved.runtime + try { + await new RoomApi(saved, { credential: saved.credential }).request('bridges', { method: 'POST', body: { kind: 'parent_receiver', recipient: saved.name, endpoint_id: this.id, pid: process.pid, state, detail: JSON.stringify({ endpoint_id: this.id, state: saved.state, error_code: saved.error_code ?? saved.receipt_error?.code ?? null }) } }) + this.update({ health_error: null }) + } catch (error) { this.update({ health_error: { code: error.code, detail: error.message } }); process.stderr.write(`${error.code}: ${error.message}\n`) } + } + eventIdentity(event, state) { + return digest([state.server_url, state.room, state.name, state.generation, + Number.isSafeInteger(event.seq) ? `seq:${event.seq}` : event.event_id ?? digest(event)]) + } + saveEvent(event, { quiet = false } = {}) { + return this.transact(state => { + const key = this.eventIdentity(event, state) + const existing = state.records.find(record => record.key === key) + if (existing) return existing + if (quiet) { + if (!state.quiet_events.some(item => item.key === key)) state.quiet_events.push({ key, event, reason: 'lattice_counts_quiet', at: now() }) + return null + } + const record = { delivery_id: randomUUID(), key, digest: digest(event), event, state: 'ready', saved_at: now(), + claim: null, receipt: null, queued_submission_id: null, accepted_at: null } + state.records.push(record) + return record + }) + } + saveCursor(cursor, sourceState) { + return this.transact(state => { state.cursor = cursor; state.source_state = sourceState }) + } + recover() { + return this.transact(state => { + for (const record of state.records) { + if (record.claim?.page_inflight && !sameProcess(record.claim.page_owner)) { + this.markUnknown(state, record, 'PARENT_PAGE_OUTPUT_INTERRUPTED') + continue + } + const durableHookClaim = join(this.root, 'queue-claims', `${record.delivery_id}.json`) + if (!record.hook_claim && existsSync(durableHookClaim)) { + const claim = readJson(durableHookClaim) + if (claim.delivery_id !== record.delivery_id || claim.endpoint_id !== this.id || claim.digest !== record.queue_digest) throw failure('PARENT_CLAIM_MISMATCH') + record.hook_claim = claim + record.hook_state = 'claimed' + } + if (record.hook_claim && record.hook_state !== 'output_complete' && !sameProcess(record.hook_claim.owner)) { + this.markUnknown(state, record, 'PARENT_CODEX_HOOK_INTERRUPTED') + continue + } + if (record.state === 'sending' && !record.claim?.handoff && !sameProcess(record.claim?.owner)) { + this.markUnknown(state, record, 'PARENT_OUTPUT_INTERRUPTED') + } + } + if (state.waiter && !sameProcess(state.waiter.owner)) { + state.waiter = null + if (state.runtime !== 'stopped') state.runtime = 'rearm_pending' + } + return state + }) + } + claim(channel, deliveryId = null) { + return this.transact(state => { + if (state.runtime === 'stopped' || state.state === 'failed') return null + const record = deliveryId ? state.records.find(item => item.delivery_id === deliveryId && item.state === 'ready') : state.records.find(item => item.state === 'ready') + if (!record) return null + // readyの先頭だけをclaimし、受信側ごとの並列出力による順序逆転を止める。 + if (state.records.some(item => item.state === 'sending')) return null + record.state = 'sending' + record.claim = { id: randomUUID(), channel, owner: processIdentity(process.pid), at: now(), handoff: false, offset: 0 } + return record + }) + } + handoff(record) { + return this.transact(state => { + const saved = this.owned(state, record) + saved.claim.handoff = true + return saved + }) + } + owned(state, record) { + const saved = state.records.find(item => item.delivery_id === record.delivery_id) + if (!saved || saved.claim?.id !== record.claim?.id) throw failure('PARENT_CLAIM_MISMATCH') + return saved + } + receiptFor(state, record, result, reason) { + return Number.isSafeInteger(record.event.seq) ? { seq: record.event.seq, recipient: state.name, result, reason, route: 'parent_receiver', endpoint_id: this.id, + receipt_revision: (record.receipt?.receipt_revision ?? 0) + 1, + queued_submission_id: record.queued_submission_id, accepted_at: record.accepted_at, pending: true } : null + } + markUnknown(state, record, code) { + if (record.state === 'unknown' && record.error_code === code) return + record.state = 'unknown'; record.error_code = code + record.receipt = this.receiptFor(state, record, 'unknown', code) + } + finish(record, options = {}) { + return this.transact(state => { + const saved = this.owned(state, record) + return this.complete(state, saved, options) + }) + } + complete(state, saved, { state: outcome = 'submitted', reason = saved.claim.channel, queued_submission_id, accepted_at, resolve_unknown = false } = {}) { + if (queued_submission_id && saved.queued_submission_id && saved.queued_submission_id !== queued_submission_id) throw failure('PARENT_QUEUE_ACCEPTANCE_CONFLICT') + if (accepted_at && saved.accepted_at && saved.accepted_at !== accepted_at) throw failure('PARENT_QUEUE_ACCEPTANCE_CONFLICT') + // queueの遅延受付は後段のunknownを消さない。受付証拠だけを追加する。 + saved.state = saved.state === 'unknown' && queued_submission_id && !resolve_unknown ? 'unknown' : outcome + saved.completed_at = now() + if (queued_submission_id) saved.queued_submission_id = queued_submission_id + if (accepted_at) saved.accepted_at = accepted_at + saved.receipt = this.receiptFor(state, saved, saved.state === 'submitted' ? 'delivered' : saved.state, saved.state === 'unknown' && outcome === 'submitted' ? saved.receipt?.reason ?? reason : reason) + if (saved.event.type === 'parent_probe' && saved.state === 'submitted') state.state = 'verified' + return saved + } + token(record, offset) { + const state = this.read() + const payload = Buffer.from(JSON.stringify({ endpoint: this.id, generation: state.generation, delivery: record.delivery_id, digest: record.digest, claim: record.claim.id, offset })).toString('base64url') + return `${payload}.${createHmac('sha256', state.continuation_key).update(payload).digest('base64url')}` + } + page(deliveryId, continuationToken = null) { + let selected, offset = 0 + if (continuationToken) { + const [payload, signature] = continuationToken.split('.') + const state = this.read() + const expected = createHmac('sha256', state.continuation_key).update(payload ?? '').digest('base64url') + if (!signature || signature.length !== expected.length || !timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) throw failure('PARENT_CONTINUATION_INVALID') + let data + try { data = JSON.parse(Buffer.from(payload, 'base64url').toString()) } catch { throw failure('PARENT_CONTINUATION_INVALID') } + selected = state.records.find(item => item.delivery_id === data.delivery) + if (!selected || data.endpoint !== this.id || data.generation !== state.generation || data.digest !== selected.digest || data.claim !== selected.claim?.id || selected.state !== 'sending' || data.offset !== selected.claim.offset || (deliveryId && deliveryId !== data.delivery)) throw failure('PARENT_CONTINUATION_MISMATCH') + offset = data.offset + } else { + const state = this.read() + selected = state.records.find(item => item.state === 'sending' && item.claim?.handoff && (!deliveryId || item.delivery_id === deliveryId)) ?? this.claim('parent_read', deliveryId) + if (selected?.claim.offset > 0) throw failure('PARENT_CONTINUATION_REQUIRED') + } + if (!selected) return null + const pageClaim = randomUUID() + this.transact(state => { + const saved = this.owned(state, selected) + if (saved.state !== 'sending' || saved.claim.offset !== offset) throw failure('PARENT_CONTINUATION_MISMATCH') + if (saved.claim.page_inflight) throw failure('PARENT_READ_IN_PROGRESS') + saved.claim.page_inflight = pageClaim + saved.claim.page_owner = processIdentity(process.pid) + saved.claim.page_started_at = now() + }) + let end = Math.min(offset + PAGE_CHARS, String(selected.event.body ?? '').length) + // UTF-16のsurrogate pairをページ境界で分割しない。 + const body = String(selected.event.body ?? '') + if (end < body.length && /[\uD800-\uDBFF]/u.test(body[end - 1])) end-- + const final = end === body.length + return { record: selected, page_claim: pageClaim, offset, end, final, result: { schema: 'peertable.parent-read-page.v1', endpoint_id: this.id, + delivery_id: selected.delivery_id, digest: selected.digest, event: { ...selected.event, body: body.slice(offset, end), message: selected.event.message ? { ...selected.event.message, body: body.slice(offset, end) } : undefined }, + offset, total_characters: body.length, complete: final, continuation_token: final ? null : this.token(selected, end) } } + } + pageWritten(page) { + return this.transact(state => { + const saved = this.owned(state, page.record) + if (saved.claim.page_inflight !== page.page_claim) throw failure('PARENT_PAGE_CLAIM_MISMATCH') + if (saved.state !== 'sending') throw failure('PARENT_PAGE_OUTPUT_UNKNOWN') + saved.claim.page_inflight = null + saved.claim.page_owner = null + if (page.final) return this.complete(state, saved, { reason: 'parent_read_complete' }) + saved.claim.offset = page.end + saved.claim.handoff = true + return saved + }) + } + async flushReceipts(api) { + const pending = this.read().records.filter(item => item.receipt?.pending) + for (const item of pending) { + const { pending: ignored, ...body } = item.receipt + try { + await api.request('deliveries', { method: 'POST', body }) + this.transact(state => { + const saved = state.records.find(record => record.delivery_id === item.delivery_id) + if (digest(saved.receipt) === digest(item.receipt)) saved.receipt.pending = false + state.receipt_error = null + }) + } catch (error) { + this.transact(state => { + const saved = state.records.find(record => record.delivery_id === item.delivery_id) + // 古いHTTP要求の失敗で、新しいreceiptの成功診断を上書きしない。 + if (digest(saved.receipt) === digest(item.receipt)) state.receipt_error = { code: error.code, detail: error.message, at: now() } + }) + throw error + } + } + } + async flushReceiptsAfterOutput(api = new RoomApi(this.read(), { credential: this.read().credential })) { + // 本文の出力成否とHTTP receiptの失敗は別。失敗分はpendingのままsourceが再送する。 + try { await this.flushReceipts(api) } + catch (error) { process.stderr.write(`${error.code ?? 'PARENT_RECEIPT_FAILED'}: ${error.message}\n`) } + } + slot(channel, receipt = null) { + return this.transact(state => { + if (state.waiter && sameProcess(state.waiter.owner)) return null + if (state.runtime === 'stopped') return null + const waiter = { waiter_id: receipt?.waiter_id ?? randomUUID(), generation: receipt?.generation ?? randomUUID(), channel, owner: processIdentity(process.pid), started_at: now(), native_task: null } + if (receipt && (state.wait_receipt?.waiter_id !== receipt.waiter_id || state.wait_receipt?.generation !== receipt.generation)) throw failure('PARENT_WAITER_MISMATCH') + state.waiter = waiter + state.runtime = channel === 'claude_asyncRewake' ? 'armed' : 'rearm_pending' + return waiter + }) + } + releaseSlot(waiter, outcome = 'rearm_pending') { + this.transact(state => { if (state.waiter?.waiter_id === waiter.waiter_id && state.waiter?.generation === waiter.generation) { state.waiter = null; state.runtime = outcome } }) + } +} + +// 通知はroomの観測データとして描画する。本文へ追加の行動命令を混ぜない。 +export function renderDelivery(state, record, { preview = false } = {}) { + const event = record.event + const body = String(event.body ?? '') + return `[Peertable room=${state.room} from=${event.from ?? 'peertable'} to=${JSON.stringify(event.to_names ?? event.to ?? state.name)} seq=${event.seq ?? event.event_id ?? record.delivery_id}]\n本文: ${preview ? body.slice(0, PAGE_CHARS) : body}\n[配送ID=${record.delivery_id} digest=${record.digest}${preview ? ' 全文取得=parent_read' : ''}]` +} diff --git a/skill/scripts/parent-delivery.test.mjs b/skill/scripts/parent-delivery.test.mjs new file mode 100644 index 00000000..5540c659 --- /dev/null +++ b/skill/scripts/parent-delivery.test.mjs @@ -0,0 +1,256 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, mkdirSync, linkSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { ParentSpool, PAGE_CHARS, renderDelivery, digest } from './parent-delivery.mjs' +import { processIdentity, processHarness, atomicJson } from './parent-platform.mjs' +import { queueCodex, codexHook, checkCodexReceiver } from './parent-receivers/codex.mjs' +import { mergeOwnedHooks, tomlHeaderKeys, replaceOwnedToml, ownedTomlBlock, hookEntries } from './parent-connect.mjs' +import { cursorEvent } from './parent-receivers/cursor.mjs' +import { clientHarness, hookContext } from './parent-caller.mjs' +const fixture = t => { + const project = mkdtempSync(join(tmpdir(), 'peertable spool 日本語 ')) + t.after(() => rmSync(project, { recursive: true, force: true })) + const spool = ParentSpool.create(project, { endpoint_id: randomUUID(), start_seq: 0, name: 'bell', room: '試験', server_url: 'https://room.invalid', harness: 'grok', caller: { harness: 'grok', conversation: '同じ会話', owner: processIdentity(process.pid) } }) + // このfixtureは本文/claimを検証し、HTTP境界は遅延receiptとsource試験で検証する。 + spool.flushReceiptsAfterOutput = async () => {} + return spool +} +const event = (seq, body = '日本語\n"引用"😀') => ({ type: 'parent_dm', seq, from: 'mio', to_names: ['bell', 'rui'], room: '試験', body, message: { seq, from: 'mio', to_names: ['bell', 'rui'], body } }) + +test('HTTP/SSE重複は同じ配送ID、原文保存後にcursorを進める', t => { + const spool = fixture(t), original = event(1) + const first = spool.saveEvent(original) + assert.equal(spool.saveEvent(structuredClone(original)).delivery_id, first.delivery_id) + assert.deepEqual(spool.read().records[0].event, original) + assert.equal(spool.read().cursor, 0) + spool.saveCursor(1, { last_seq: 1 }) + assert.equal(spool.read().cursor, 1) + assert.match(renderDelivery(spool.read(), first), /日本語\n"引用"😀/u) +}) + +test('複数processのclaim競合は1通だけを所有し、孤児sendingはunknownで再送しない', async t => { + const spool = fixture(t); spool.saveEvent(event(1)); spool.saveEvent(event(2)) + const url = new URL('./parent-delivery.mjs', import.meta.url).href + const code = `import {ParentSpool} from ${JSON.stringify(url)}; const s=new ParentSpool(process.argv[1],process.argv[2]); console.log(JSON.stringify(s.claim('race')?.delivery_id??null));` + const result = await Promise.all(Array.from({ length: 3 }, () => new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['--input-type=module', '-e', code, spool.project, spool.id]); let out = '', err = '' + child.stdout.on('data', data => { out += data }); child.stderr.on('data', data => { err += data }) + child.on('error', reject); child.on('close', status => status ? reject(new Error(err)) : resolve(JSON.parse(out))) + }))) + assert.equal(result.filter(Boolean).length, 1) + const recovered = spool.recover() + assert.equal(recovered.records[0].state, 'unknown') + assert.equal(recovered.records[0].event.body, event(1).body) + assert.equal(spool.claim('next').event.seq, 2) +}) + +test('長文は継続tokenで原文全量を回収し、最後の出力完了までackしない', t => { + const spool = fixture(t), body = 'あ'.repeat(PAGE_CHARS - 1) + '😀' + '\n"末尾"'.repeat(4000) + const saved = spool.saveEvent(event(7, body)); let page = spool.page(saved.delivery_id), combined = '' + assert.equal(spool.read().records[0].receipt, null) + const tampered = page.result.continuation_token.replace(/^./u, 'x') + assert.throws(() => spool.page(saved.delivery_id, tampered), /PARENT_CONTINUATION_INVALID/u) + while (page) { + combined += page.result.event.body + const token = page.result.continuation_token + spool.pageWritten(page) + if (!token) break + assert.equal(spool.read().records[0].state, 'sending') + page = spool.page(saved.delivery_id, token) + } + assert.equal(combined, body) + assert.equal(spool.read().records[0].state, 'submitted') + assert.equal(spool.read().records[0].receipt.result, 'delivered') +}) + +test('handoff本文を同時readしても同じpageを二重出力しない', t => { + const spool = fixture(t); spool.saveEvent(event(1)); const record = spool.claim('background'); spool.handoff(record) + const page = spool.page(record.delivery_id) + assert.throws(() => spool.page(record.delivery_id), /PARENT_READ_IN_PROGRESS/u) + spool.pageWritten(page) + assert.equal(spool.page(record.delivery_id), null) +}) + +test('page選択後に別出力がoffsetを進めた時、古いsnapshotを出力しない', t => { + const spool = fixture(t); spool.saveEvent(event(1, 'あ'.repeat(PAGE_CHARS * 2))) + const record = spool.claim('background'); spool.handoff(record) + const transact = spool.transact.bind(spool) + let interleave = true + spool.transact = operation => { + if (interleave) { + interleave = false + transact(state => { state.records[0].claim.offset = PAGE_CHARS }) + } + return transact(operation) + } + assert.throws(() => spool.page(record.delivery_id), /PARENT_CONTINUATION_MISMATCH/u) + assert.equal(spool.read().records[0].claim.page_inflight, undefined) +}) + +test('別MCP processがpage出力前に終了すると原文/offset/受付を保持したunknownへ回収する', async t => { + const spool = fixture(t); spool.saveEvent(event(1, '長文'.repeat(PAGE_CHARS))); const record = spool.claim('background'); spool.handoff(record) + const url = new URL('./parent-delivery.mjs', import.meta.url).href + const code = `import {ParentSpool} from ${JSON.stringify(url)};const s=new ParentSpool(process.argv[1],process.argv[2]);const page=s.page(process.argv[3]);console.log(page.result.delivery_id);` + await new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['--input-type=module', '-e', code, spool.project, spool.id, record.delivery_id]); let err = '' + child.stdout.resume(); child.stderr.on('data', data => { err += data }); child.on('error', reject); child.on('close', code => code ? reject(new Error(err)) : resolve()) + }) + const before = spool.read().records[0] + assert.ok(before.claim.page_owner.pid !== process.pid); assert.equal(before.claim.offset, 0) + const recovered = spool.recover().records[0] + assert.equal(recovered.state, 'unknown'); assert.equal(recovered.error_code, 'PARENT_PAGE_OUTPUT_INTERRUPTED') + assert.equal(recovered.event.body, '長文'.repeat(PAGE_CHARS)); assert.equal(recovered.claim.offset, 0) + assert.equal(spool.page(record.delivery_id), null) + assert.equal(recovered.receipt.result, 'unknown') +}) + +test('queue受付後unknownへ変わっても受付証拠を保持し、receiptだけを再試行する', async t => { + const spool = fixture(t); spool.saveEvent(event(1)); const record = spool.claim('codex_queue') + spool.finish(record, { queued_submission_id: 'queue-official-id', accepted_at: '2026-09-28T00:00:00Z' }) + spool.finish(record, { state: 'unknown', reason: 'queue_delete_then_output_interrupted' }) + await assert.rejects(spool.flushReceipts({ request: async () => { throw new Error('offline') } }), /offline/u) + const posted = [] + await spool.flushReceipts({ request: async (path, args) => posted.push(args.body) }) + assert.equal(posted[0].result, 'unknown'); assert.equal(posted[0].queued_submission_id, 'queue-official-id') + assert.equal(posted[0].accepted_at, '2026-09-28T00:00:00Z') + assert.equal(spool.claim('retry'), null) + assert.equal(spool.read().records[0].receipt.pending, false) +}) + +test('古いreceiptの遅延HTTP失敗は新しいreceiptの成功診断を上書きしない', async t => { + const spool = fixture(t); spool.saveEvent(event(1)); const record = spool.claim('fixture'); spool.finish(record) + let rejectOld + const failure = Object.assign(new Error('遅延失敗'), { code: 'HTTP_FAILURE' }) + const old = spool.flushReceipts({ request: () => new Promise((resolve, reject) => { rejectOld = reject }) }) + const rejected = assert.rejects(old, /遅延失敗/u) + spool.finish(record, { state: 'unknown', reason: 'OUTPUT_INTERRUPTED' }) + await spool.flushReceipts({ request: async () => ({ ok: true }) }) + rejectOld(failure); await rejected + assert.equal(spool.read().receipt_error, null) + assert.equal(spool.read().records[0].receipt.pending, false) +}) + +test('未知clientとshell引数内のharness名を本人へ推測割当しない', () => { + assert.throws(() => clientHarness({ name: 'not-codex-but-contains-codex' }), /未確認/u) + assert.equal(clientHarness({ name: 'codex-mcp-client' }), 'codex') + assert.equal(clientHarness({ name: 'claude-code' }), 'claude') + assert.equal(clientHarness({ name: 'cursor-vscode 2.6.0' }), 'cursor') + assert.throws(() => clientHarness({ name: 'cursor-vscode-fake' }), /未確認/u) + assert.equal(clientHarness({ name: 'grok-shell-peertable_parent' }), 'grok') + assert.equal(processHarness({ executable: '/bin/zsh', command: 'zsh -c "codex app-server"' }), null) + assert.equal(processHarness({ executable: '/usr/bin/node', command: 'node script.mjs claude /Cursor.app' }), null) + assert.equal(processHarness({ executable: '/usr/bin/node', command: 'node /global/node_modules/@openai/codex/bin/codex.js app-server' }), 'codex') +}) + +test('Claude agent_id付き会話は相関保存前に明示拒否する', () => { + assert.throws(() => hookContext('claude', { agent_id: 'native-agent', session_id: 'session', tool_use_id: 'use', tool_name: 'parent_join', tool_input: {} }), { code: 'CLAUDE_PARENT_SUBAGENT_UNSUPPORTED' }) +}) + +test('Codex native thread_spawnはqueue/hookの健康確認へ進む前に明示拒否する', async () => { + const calls = []; let closed = false + await assert.rejects(checkCodexReceiver('.', { conversation: 'native-child' }, 'owned-hook', { connect: async () => ({ + request: async method => { calls.push(method); return { thread: { id: 'native-child', source: { subAgent: { thread_spawn: {} } } } } }, + close: async () => { closed = true }, + }) }), { code: 'CODEX_PARENT_UNSUPPORTED' }) + assert.deepEqual(calls, ['thread/read']); assert.equal(closed, true) +}) + +test('TOML literal/basic quoted own tableの置換は他設定の字面と順序を保持する', () => { + const text = '#利用者\n[mcp_servers.other]\ncommand="other"\n[mcp_servers.\'peertable_parent\']\ncommand="ours"\n[hooks.state.\'C:\\path:key\']\nenabled=true\n' + assert.deepEqual(tomlHeaderKeys("[hooks.state.'C:\\path:key']"), ['hooks', 'state', 'C:\\path:key']) + const owned = ownedTomlBlock(text).block + assert.throws(() => replaceOwnedToml(text, ''), /PARENT_CONFIG_OWNERSHIP_CONFLICT/u) + assert.equal(replaceOwnedToml(text, '', digest(owned)), '#利用者\n[mcp_servers.other]\ncommand="other"\n[hooks.state.\'C:\\path:key\']\nenabled=true\n') +}) + +test('hook更新は外部の順序・承認・入力を保持する', () => { + const foreign = { hooks: [{ type: 'command', command: '利用者のhook', enabled: true }] } + const ours = { hooks: [{ command: '自分のhook' }] } + const next = mergeOwnedHooks({ permissions: { allow: ['read'] }, hooks: { Stop: [foreign, ours, foreign] } }, { Stop: [{ hooks: [{ command: '新hook' }] }] }, [JSON.stringify(['自分のhook', []])]) + assert.deepEqual(next.hooks.Stop[0], foreign); assert.deepEqual(next.hooks.Stop[2], foreign) + assert.deepEqual(next.permissions, { allow: ['read'] }) +}) + +test('別hostが正式endpointを置き換えると旧本文を新会話へ移さず旧receiverを閉じる', async t => { + const spool = fixture(t); spool.saveEvent(event(1)); const replacement = randomUUID() + await assert.rejects(spool.assertCurrentEndpoint({ members: async () => [{ name: 'bell', delivery: { kind: 'parent_receiver', endpoint_id: replacement } }] }), /PARENT_ENDPOINT_SUPERSEDED/u) + assert.equal(spool.read().runtime, 'stopped'); assert.equal(spool.read().records[0].state, 'failed') + assert.equal(spool.read().records[0].event.body, event(1).body) + assert.equal(spool.claim('旧会話への配送'), null) +}) + +test('queueの明示拒否はfailed、応答不明はunknownになる', async t => { + for (const rejected of [true, false]) { + const spool = fixture(t); spool.saveEvent(event(1)) + const error = Object.assign(new Error('RPC失敗'), { code: rejected ? 'PARENT_CODEX_RPC_REJECTED' : 'PARENT_CODEX_DISCONNECTED', rpc_rejected: rejected }) + await assert.rejects(queueCodex(spool, { checkTarget: async () => {}, connect: async () => ({ request: async () => { throw error }, close: async () => {} }) }), /RPC失敗/u) + assert.equal(spool.read().records[0].state, rejected ? 'failed' : 'unknown') + } +}) + +test('queue受付保存より先にhookが削除・出力失敗しても遅延受付はunknownを消さない', async t => { + const spool = fixture(t); spool.saveEvent(event(1)) + let accept, invoked + const started = new Promise(resolve => { invoked = resolve }) + const queueing = queueCodex(spool, { checkTarget: async () => {}, connect: async () => ({ request: async () => { invoked(); return new Promise(resolve => { accept = resolve }) }, close: async () => {} }) }) + await started + const saved = spool.read().records[0], deleted = [] + const hookClient = { request: async (method, args) => method === 'thread/queue/list' ? { data: [ + { id: 'foreign-id', clientUserMessageId: 'foreign-message', input: [{ type: 'text', text: '他製品' }] }, + { id: 'official-accepted', clientUserMessageId: saved.delivery_id, input: [{ type: 'text', text: saved.queue_text }] }, + ] } : (deleted.push(args.queuedSubmissionId), { deleted: true }), close: async () => {} } + await assert.rejects(codexHook(spool, { session_id: '同じ会話', hook_event_name: 'PostToolUse' }, async () => { throw new Error('出力断') }, { checkTarget: async () => {}, connect: async () => hookClient }), /出力断/u) + assert.deepEqual(deleted, ['official-accepted']) + accept({ queuedSubmission: { id: 'official-accepted' } }); await queueing + const result = spool.read().records[0] + assert.equal(result.state, 'unknown'); assert.equal(result.queued_submission_id, 'official-accepted') + assert.equal(result.receipt.result, 'unknown'); assert.equal(result.hook_state, 'deleted') +}) + +test('hardlink後・削除後の実child終了は受付証拠を残してunknownになる', async t => { + const deliveryUrl = new URL('./parent-delivery.mjs', import.meta.url).href, platformUrl = new URL('./parent-platform.mjs', import.meta.url).href + for (const phase of ['before_state_save', 'deleted']) { + const spool = fixture(t); spool.saveEvent(event(1)); const record = spool.claim('codex_queue') + spool.transact(state => { const item = spool.owned(state, record); item.queue_digest = 'same-digest'; item.queue_thread = '同じ会話' }) + spool.finish(record, { queued_submission_id: 'official-accepted', accepted_at: '2026-09-28T00:00:00Z' }) + const code = `import {ParentSpool} from ${JSON.stringify(deliveryUrl)};import {atomicJson,processIdentity} from ${JSON.stringify(platformUrl)};import {mkdirSync,linkSync} from 'node:fs';import {randomUUID} from 'node:crypto';const s=new ParentSpool(process.argv[1],process.argv[2]);const record=s.read().records[0];const claim={id:randomUUID(),delivery_id:record.delivery_id,endpoint_id:s.id,digest:'same-digest',owner:processIdentity(process.pid)};mkdirSync(s.root+'/queue-claims');const candidate=s.root+'/queue-claims/candidate.json';atomicJson(candidate,claim);linkSync(candidate,s.root+'/queue-claims/'+record.delivery_id+'.json');if(process.argv[3]==='deleted')s.transact(state=>{const item=s.owned(state,record);item.hook_claim=claim;item.hook_state='deleted'});` + await new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['--input-type=module', '-e', code, spool.project, spool.id, phase]); let err = '' + child.stderr.on('data', data => { err += data }); child.on('error', reject); child.on('close', code => code ? reject(new Error(err)) : resolve()) + }) + const recovered = spool.recover().records[0] + assert.equal(recovered.state, 'unknown'); assert.equal(recovered.receipt.queued_submission_id, 'official-accepted') + assert.equal(recovered.event.body, event(1).body); assert.equal(spool.claim('自動再送'), null) + } +}) + +test('ClaudeのPost/Stop同時開始は同じsessionで1slot、期限controlは本文成功にしない', async t => { + const spool = fixture(t), gate = join(spool.root, 'gate') + const deliveryUrl = new URL('./parent-delivery.mjs', import.meta.url).href, receiverUrl = new URL('./parent-receivers/claude.mjs', import.meta.url).href + const code = `import {ParentSpool} from ${JSON.stringify(deliveryUrl)};import {claudeRewake} from ${JSON.stringify(receiverUrl)};import fs from 'node:fs';import {setTimeout as delay} from 'node:timers/promises';const s=new ParentSpool(process.argv[1],process.argv[2]);console.log('準備');while(!fs.existsSync(process.argv[3]))await delay(10);let outputs=[];const status=await claudeRewake(s,async text=>outputs.push(text),{leaseMs:500,slotRoot:s.root+'/slots',listEndpoints:()=>[]});console.log(JSON.stringify({status,outputs}));` + let ready = 0 + const result = await Promise.all(Array.from({ length: 2 }, () => new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['--input-type=module', '-e', code, spool.project, spool.id, gate]); let out = '', err = '' + child.stdout.on('data', data => { out += data; if (out.includes('準備') && !child.readyReported) { child.readyReported = true; if (++ready === 2) writeFileSync(gate, '') } }) + child.stderr.on('data', data => { err += data }); child.on('error', reject) + child.on('close', status => status ? reject(new Error(err)) : resolve(JSON.parse(out.trim().split('\n').at(-1)))) + }))) + assert.deepEqual(result.map(item => item.status).sort(), [0, 2]) + assert.match(result.find(item => item.status === 2).outputs[0], /PARENT_RECEIVER_EXPIRED/u) + assert.equal(spool.read().runtime, 'rearm_pending'); assert.equal(spool.read().records.length, 0) +}) + +test('Cursor afterMCPExecutionとpostToolUseは同じclaimを共有しbusy本文を1回だけ出す', async t => { + assert.ok(hookEntries('cursor').afterMCPExecution) + const spool = fixture(t); spool.saveEvent(event(1)); const output = [] + const options = { checkTarget: async () => {} } + const native = { hook_event_name: 'afterMCPExecution', conversation_id: '同じ会話', tool_use_id: 'official-use-id', mcp_server_name: 'peertable_parent', tool_name: 'parent_read', tool_input: '{"endpoint_id":"returned-endpoint"}', result_json: '{"content":[]}' } + const results = await Promise.all([cursorEvent(spool, native, async value => output.push(value), options), cursorEvent(spool, { ...native, hook_event_name: 'postToolUse', tool_name: 'MCP:parent_read', tool_input: { endpoint_id: 'returned-endpoint' } }, async value => output.push(value), options)]) + assert.equal(results.filter(Boolean).length, 1); assert.equal(output.length, 1) + assert.match(output[0].additional_context, /日本語\n"引用"😀/u) + assert.equal(spool.read().records[0].receipt.result, 'delivered') +}) diff --git a/skill/scripts/parent-hook.mjs b/skill/scripts/parent-hook.mjs new file mode 100644 index 00000000..77c054ff --- /dev/null +++ b/skill/scripts/parent-hook.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +// Peertable所有hook。互換読込みも実processのハーネスと照合して1 adapterだけ動かす。 +import { hookContext, endpointsFor, verifyJoinHook, assertClaudeParent } from './parent-caller.mjs' +import { harnessProcess, failure } from './parent-platform.mjs' +import { claudeRewake } from './parent-receivers/claude.mjs' +import { cursorEvent } from './parent-receivers/cursor.mjs' +import { bindNativeWait } from './parent-receivers/background.mjs' +import { codexHook } from './parent-receivers/codex.mjs' +import { stopEndpoint } from './parent-runtime.mjs' + +const harness = process.argv[2] +const writeJson = value => new Promise((resolve, reject) => process.stdout.write(`${JSON.stringify(value)}\n`, error => error ? reject(error) : resolve())) +const writeError = value => new Promise((resolve, reject) => process.stderr.write(`${value}\n`, error => error ? reject(error) : resolve())) +try { + let raw = ''; for await (const chunk of process.stdin) raw += chunk + const event = JSON.parse(raw) + if (harness === 'claude') assertClaudeParent(event) + const eventName = harness === 'grok' ? event.hook_event_name : event.hook_event_name + const pre = eventName === (harness === 'cursor' ? 'preToolUse' : 'PreToolUse') + const empty = pre && harness === 'cursor' ? { permission: 'allow' } : {} + const owner = harnessProcess(harness) + if (!owner) { await writeJson(empty) } + else if (pre) { + const saved = hookContext(harness, event) + await writeJson(!saved || harness === 'claude' ? empty : harness === 'cursor' + ? { permission: 'allow', updated_input: saved.updated } + : { hookSpecificOutput: { hookEventName: 'PreToolUse', updatedInput: saved.updated } }) + } else { + const conversation = harness === 'grok' ? event.sessionId : harness === 'cursor' ? event.conversation_id : event.session_id + if (!conversation) throw failure('PARENT_CALLER_UNBOUND') + const endpoints = endpointsFor({ caller: { harness, conversation } }).filter(spool => { + const bound = spool.read().caller.owner + return bound.pid === owner.pid && bound.started === owner.started && spool.read().runtime !== 'stopped' + }) + let emitted = false + if (['PostToolUse', 'postToolUse', 'afterMCPExecution'].includes(eventName) && harness !== 'codex') verifyJoinHook(harness, event, owner) + if (harness === 'claude' && ['PostToolUse', 'Stop'].includes(eventName)) { + const code = await claudeRewake(endpoints, writeError) + process.exitCode = code + emitted = code === 2 + } + for (const spool of endpoints) { + if (['SessionEnd', 'sessionEnd'].includes(eventName)) { await stopEndpoint(spool); continue } + if (harness === 'codex' && ['PostToolUse', 'Stop'].includes(eventName)) { + if (await codexHook(spool, event, writeJson)) { emitted = true; break } + } else if (['cursor', 'grok'].includes(harness) && ['postToolUse', 'PostToolUse', 'afterMCPExecution'].includes(eventName)) { + try { if (harness === 'grok') await bindNativeWait(spool, event, harness) } + catch (error) { spool.update({ runtime: 'failed', error_code: error.code, error_detail: error.message }); throw error } + if (harness === 'cursor' && await cursorEvent(spool, event, writeJson)) { emitted = true; break } + } + } + if (!emitted && harness !== 'claude') await writeJson(empty) + } +} catch (error) { + const code = error.code ?? 'PARENT_HOOK_FAILED' + await writeError(`${code}: ${error.message}`) + if (harness === 'cursor') await writeJson({ permission: 'deny', agent_message: code }) + process.exitCode = 1 +} diff --git a/skill/scripts/parent-join.sh b/skill/scripts/parent-join.sh index e39d4cee..f7614ae5 100755 --- a/skill/scripts/parent-join.sh +++ b/skill/scripts/parent-join.sh @@ -1,115 +1,4 @@ #!/bin/bash -# 親(ベル等)が room へ着卓する。 -# usage: parent-join.sh [name] [model] [effort] [harness] -# name 既定は bell。broadcast廃止に伴いkickoff投稿は行わない。 -# model / effort / harness は任意。親はオーナーの対話セッション(決定40)なので、席と違って -# 起動時に確定した値を script が知らない——**渡された時だけ**参加者一覧の素性として登録する。 -# 渡さなければ欄ごと出ない(「不明」ではなく「素性を名乗っていない」)。 -# harness は claude(既定)、codex、または grok。model だけ渡して harness を渡さない場合は -# claude とみなす(後方互換)。 -# 親は MCP を後付けできないので room へは HTTP API 直で入る(決定40 の operating notes)。 +# 互換入口。接続処理はNodeが所有し、本人の束縛は親MCPが行う。 set -e -proj="$1"; name="${2:-bell}"; model="$3"; effort="$4"; harness="$5" -mission="" -if [ "${6:-}" = "--mission" ]; then mission="${7:-}"; fi -state="$proj/.team/setup-state.json" -room=$(python3 -c "import json;print(json.load(open('$state'))['room'])") -url=$(python3 -c "import json;print(json.load(open('$state'))['server_url'])") -mode=$(python3 -c "import json;print(json.load(open('$state')).get('mode',''))") - -if [ -z "${PEERTABLE_POST_TOKEN:-}" ] && [ -f "$HOME/.config/peertable.env" ]; then - . "$HOME/.config/peertable.env" -fi - -# 親はAiterm席ではない。Claude/Codex/Grokとも、親自身が所有するparent-watchを配送先にする。 -# tmux observeやCodex thread IDを登録すると、通常席bridge/外部resumeへ誤配送される。 -parent_harness="$harness" -if [ -z "$parent_harness" ] && [ -n "$model" ]; then - case "$model" in - gpt-*|o[0-9]*) parent_harness=codex ;; - grok*) parent_harness=grok ;; - claude*|opus*|sonnet*|haiku*|fable*) parent_harness=claude ;; - esac -fi -if [ "$(node -p 'process.platform')" = win32 ]; then - node "$(dirname "$0")/platform/windows/register-parent-member.mjs" "$url" "$room" "$name" "$model" "$effort" "$parent_harness" "$mission" -else -member=$(python3 - "$name" "$model" "$effort" "$parent_harness" "$mission" <<'PY' -import json, sys -name, model, effort, harness, mission = sys.argv[1:6] -# 台帳には canonical 欄だけを登録する(settings / role 単数の重複欄は 2026-08-22 廃止) -body = {'name': name, 'roles': ['統括']} -if harness: - body['harness'] = harness - body['vendor'] = harness # 旧版 room server 互換 -if model: - body['model'] = model -if effort: - body['effort'] = effort -if mission: - body['mission'] = mission -body['observe'] = None -body['delivery'] = {'kind': 'parent_watch', 'host': harness or ''} -print(json.dumps(body, ensure_ascii=False)) -PY -) -curl -sf -X POST "$url/api/$room/members" \ - -H "X-Peertable-Token: $PEERTABLE_POST_TOKEN" -H 'content-type: application/json' \ - -d "$member" > /dev/null -fi -echo "joined: ${name}(room=${room})" - -here="$(cd "$(dirname "$0")" && pwd)" - -# owner裁定[46]④: 子processのexportは親shellへ伝播しないため、Lattice mutation -# (todo reopen 等)に要る actor 環境変数は親自身が source する持続ファイルとして配る。 -if [ "$mode" = "lattice" ]; then - env_file="$proj/.team/parent-env.sh" - cat > "$env_file" < /dev/null; then - echo "EAR_PROBE_SENT: nonce=${probe_nonce}。着卓完了の条件は、上記の監視経由でこのnonceを受信すること。受信できないなら耳は繋がっていない=着卓失敗として扱い、監視を張り直す" - else - echo "WARN: EAR_PROBE_POST_FAILED: 耳疎通probeを投稿できない(監視の受信確認ができないまま)" >&2 - fi -else - echo "WARN: PARENT_WATCH_PRIME_FAILED: 親(${name})のDM cursorを準備できない" >&2 -fi - -curl -sf "$url/api/$room/members" | python3 -c "import json,sys;print('members:', ', '.join(m['name'] for m in json.load(sys.stdin)['members']))" +exec node "$(dirname "$0")/parent-prepare.mjs" "$@" diff --git a/skill/scripts/parent-platform.mjs b/skill/scripts/parent-platform.mjs new file mode 100644 index 00000000..cadf2f10 --- /dev/null +++ b/skill/scripts/parent-platform.mjs @@ -0,0 +1,151 @@ +// OSへの適合だけを集約する。所有者はPIDと開始identityの組で照合する。 +import { execFileSync } from 'node:child_process' +import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, readlinkSync, readdirSync } from 'node:fs' +import { dirname, join, basename } from 'node:path' +import { createRequire } from 'node:module' +import { homedir } from 'node:os' +import { randomUUID } from 'node:crypto' + +export const parentHome = () => join(homedir(), '.peertable', 'parent-receivers') +export const failure = (code, detail = code) => Object.assign(new Error(detail), { code }) +export const readJson = file => JSON.parse(readFileSync(file, 'utf8')) +export function atomicJson(file, value) { + mkdirSync(dirname(file), { recursive: true, mode: 0o700 }) + const temp = `${file}.${process.pid}.${randomUUID()}.tmp` + writeFileSync(temp, `${JSON.stringify(value)}\n`, { mode: 0o600, flag: 'wx' }) + renameSync(temp, file) +} +export const psQuote = value => `'${String(value).replaceAll("'", "''")}'` +export const posixQuote = value => `'${String(value).replaceAll("'", "'\\''")}'` +export function shellCommand(executable, args, platform = process.platform) { + return platform === 'win32' ? `& ${[executable, ...args].map(psQuote).join(' ')}` : [executable, ...args].map(posixQuote).join(' ') +} +// Windowsのcommand形式hookもPowerShell 7を明示して実行する。 +export function hookCommand(executable, args, platform = process.platform) { + if (platform !== 'win32') return shellCommand(executable, args, platform) + const script = `${shellCommand(executable, args, platform)}; exit $LASTEXITCODE` + return `pwsh.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand ${Buffer.from(script, 'utf16le').toString('base64')}` +} +export function windowsArgumentList(args) { + return args.map(value => `"${String(value).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, '$1$1')}"`).join(' ') +} +let ownIdentity = null +export function processIdentity(pid, { includeExecutable = true } = {}) { + if (!Number.isSafeInteger(Number(pid)) || Number(pid) <= 0) throw failure('PARENT_PROCESS_ID_INVALID') + if (Number(pid) === process.pid && ownIdentity && (!includeExecutable || ownIdentity.executable)) return ownIdentity + const result = identity => { + // 自分自身の開始identityはprocessの存命中に変わらない。他PIDは毎回OSへ照会する。 + if (identity.pid === process.pid) ownIdentity = identity + return identity + } + try { + if (process.platform === 'win32') { + const script = `$ErrorActionPreference='Stop'; $p=Get-CimInstance Win32_Process -Filter 'ProcessId = ${Number(pid)}'; if($p){ @{pid=[int]$p.ProcessId; parent=[int]$p.ParentProcessId; started=$p.CreationDate.ToUniversalTime().ToString('o'); command=$p.CommandLine; executable=$p.ExecutablePath} | ConvertTo-Json -Compress }` + const raw = execFileSync('pwsh.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() + if (!raw) return null + const identity = JSON.parse(raw) + if (identity.pid !== Number(pid) || !Number.isSafeInteger(identity.parent) || typeof identity.started !== 'string' || !identity.started || (includeExecutable && (typeof identity.executable !== 'string' || !identity.executable || typeof identity.command !== 'string'))) throw failure('PARENT_PROCESS_API_SCHEMA_INVALID') + return result(identity) + } + if (process.platform === 'linux') { + const stat = readFileSync(`/proc/${pid}/stat`, 'utf8') + const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ') + if (!stat.startsWith(`${pid} (`) || !Number.isSafeInteger(Number(fields[1])) || !/^\d+$/u.test(fields[19] ?? '')) throw failure('PARENT_PROCESS_API_SCHEMA_INVALID') + if (fields[0] === 'Z') return null + return result({ pid: Number(pid), parent: Number(fields[1]), started: `${readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim()}:${fields[19]}`, executable: includeExecutable ? readlinkSync(`/proc/${pid}/exe`) : null, command: includeExecutable ? readFileSync(`/proc/${pid}/cmdline`, 'utf8').replaceAll('\0', ' ') : null }) + } + const raw = execFileSync('/bin/ps', ['-ww', '-p', String(pid), '-o', 'ppid=', '-o', 'lstart=', '-o', 'command='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() + const match = /^(\d+)\s+(\w+\s+\w+\s+\d+\s+\d+:\d+:\d+\s+\d+)\s+(.+)$/u.exec(raw) + // macOSのps commは15文字で切れる。OSの開いているprogram textから実pathを取る。 + const executable = !includeExecutable ? null : Number(pid) === process.pid ? process.execPath + : execFileSync('/usr/sbin/lsof', ['-a', '-p', String(pid), '-d', 'txt', '-Fn'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).split('\n').find(line => line.startsWith('n/'))?.slice(1) + if (!match || (includeExecutable && !executable)) throw failure('PARENT_PROCESS_API_SCHEMA_INVALID') + return result({ pid: Number(pid), parent: Number(match[1]), started: match[2], executable, command: match[3] }) + } catch (error) { + if (process.platform === 'linux' && error.code === 'ENOENT' && String(error.path).startsWith(`/proc/${pid}/`)) { + try { readFileSync(`/proc/${pid}/stat`) } + catch (probe) { if (probe.code === 'ENOENT') return null; throw failure('PARENT_PROCESS_API_FAILED', probe.message) } + } + if (process.platform === 'darwin' && error.status === 1) { + try { execFileSync('/bin/ps', ['-p', String(pid), '-o', 'pid='], { stdio: ['ignore', 'pipe', 'pipe'] }) } + catch (probe) { if (probe.status === 1 && !String(probe.stdout ?? '').trim() && !String(probe.stderr ?? '').trim()) return null; throw failure('PARENT_PROCESS_API_FAILED', probe.message) } + } + if (error.code === 'ENOENT' && process.platform === 'win32') throw failure('PARENT_PWSH7_MISSING') + if (error.code?.startsWith('PARENT_')) throw error + throw failure('PARENT_PROCESS_API_FAILED', `PID ${pid}: ${error.message}`) + } +} +export const sameProcess = owner => owner && processIdentity(owner.pid, { includeExecutable: false })?.started === owner.started +export function processDescendsFrom(owner, ancestor) { + if (!sameProcess(owner) || !sameProcess(ancestor)) return false + for (let pid = owner.pid, depth = 0; pid > 0 && depth < 32; depth++) { + const identity = processIdentity(pid, { includeExecutable: false }) + if (!identity) return false + if (identity.pid === ancestor.pid) return identity.started === ancestor.started + if (identity.parent === pid) return false + pid = identity.parent + } + return false +} +export function processHarness(identity) { + const executable = basename(identity.executable ?? '').toLowerCase() + const native = { 'claude': 'claude', 'claude.exe': 'claude', 'codex': 'codex', 'codex.exe': 'codex', 'grok': 'grok', 'grok.exe': 'grok', 'cursor-agent': 'cursor', 'cursor.exe': 'cursor', 'cursor': 'cursor' } + if (native[executable]) return native[executable] + if (executable !== 'node' && executable !== 'node.exe') return null + // Node直下の実entryだけを読む。shell -cや後続引数に含まれる名前は本人の根拠にしない。 + const tokens = (identity.command ?? '').match(/"[^"]*"|'[^']*'|[^\s]+/gu) ?? [] + const script = tokens[1]?.replace(/^['"]|['"]$/gu, '') ?? '' + if (/@anthropic-ai[/\\]claude-code[/\\](?:cli\.js|bin[/\\]claude)/u.test(script)) return 'claude' + if (/@openai[/\\]codex[/\\]bin[/\\]codex\.js$/u.test(script)) return 'codex' + if (/cursor-agent[/\\]versions[/\\][^/\\]+[/\\](?:index\.js|cursor-agent)$/u.test(script)) return 'cursor' + if (/grok-cli[/\\](?:dist[/\\])?(?:index|cli)\.js$/u.test(script)) return 'grok' + return null +} +export function harnessProcess(harness, start = process.ppid) { + for (let pid = start, depth = 0; pid > 0 && depth < 24; depth++) { + const identity = processIdentity(pid) + if (!identity) return null + const detected = processHarness(identity) + if (detected) return detected === harness ? identity : null + if (identity.parent === pid) return null + pid = identity.parent + } + return null +} +export function codexPackageExecutable(packageRoot, { platform = process.platform, arch = process.arch } = {}) { + const manifest = readJson(join(packageRoot, 'package.json')) + if (manifest.name !== '@openai/codex' || !manifest.bin?.codex) throw failure('PARENT_CODEX_PACKAGE_INVALID') + const packageName = `@openai/codex-${platform}-${arch}` + if (!Object.hasOwn(manifest.optionalDependencies ?? {}, packageName)) throw failure('PARENT_CODEX_PLATFORM_UNSUPPORTED') + const require = createRequire(join(packageRoot, manifest.bin.codex)) + const platformRoot = dirname(require.resolve(`${packageName}/package.json`)) + const nativeManifest = readJson(join(platformRoot, 'package.json')) + if (nativeManifest.os && !nativeManifest.os.includes(platform)) throw failure('PARENT_CODEX_PACKAGE_PLATFORM_MISMATCH') + const vendor = join(platformRoot, 'vendor') + const binaries = readdirSync(vendor).map(target => join(vendor, target, 'bin', platform === 'win32' ? 'codex.exe' : 'codex')).filter(existsSync) + if (binaries.length !== 1) throw failure('PARENT_CODEX_NATIVE_EXECUTABLE_UNRESOLVED') + return binaries[0] +} +export function resolveExecutable(name) { + if (process.platform !== 'win32') { + const paths = (process.env.PATH ?? '').split(':') + const found = paths.map(path => join(path, name)).find(existsSync) + if (!found) throw failure('PARENT_EXECUTABLE_MISSING', `${name}が見つかりません`) + return found + } + const script = `$c=Get-Command ${psQuote(name)} -ErrorAction Stop; $c.Source` + const path = execFileSync('pwsh.exe', ['-NoProfile', '-Command', script], { encoding: 'utf8' }).trim() + if (/\.ps1$/iu.test(path)) { + // npmのshimではなく公式native実体を選ぶ。実体の候補を推測して呼び出さない。 + const content = readFileSync(path, 'utf8') + if (name === 'codex') { + const relative = /\$basedir[\\/]([^"\r\n]*@openai[\\/]codex[\\/]bin[\\/]codex\.js)/u.exec(content)?.[1] + if (!relative) throw failure('PARENT_CODEX_NPM_ENTRY_UNRESOLVED') + return codexPackageExecutable(dirname(dirname(join(dirname(path), relative)))) + } + const exe = [...content.matchAll(/\$basedir[\\/]([^"\r\n]+\.exe)/gu)].map(match => join(dirname(path), match[1])).find(existsSync) + if (exe) return exe + throw failure('PARENT_NATIVE_EXECUTABLE_UNRESOLVED', `${path}の公式実体を解決できません`) + } + return path +} diff --git a/skill/scripts/parent-prepare.mjs b/skill/scripts/parent-prepare.mjs new file mode 100644 index 00000000..c78b7b1b --- /dev/null +++ b/skill/scripts/parent-prepare.mjs @@ -0,0 +1,22 @@ +#!/usr/bin/env node +// 旧shell入口からも同じNode接続処理を使う。実会話への束縛はMCPで行う。 +import { join, resolve } from 'node:path' +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { connectCommand } from './parent-connect.mjs' +import { setupFor, actorEnvironment } from './parent-runtime.mjs' +export async function prepareParent(args, { connect = connectCommand } = {}) { + const [projectArg, name = 'bell', model, effort, harness, flag, mission] = args + const project = resolve(projectArg) + setupFor(project) + const connection = await connect(harness ? ['--target', harness] : []) + if (connection.status === 'failed') throw Object.assign(new Error(JSON.stringify(connection)), { code: 'PARENT_CONNECT_FAILED' }) + const actor_environment = actorEnvironment(project, name) + return { schema: 'peertable.parent-prepare.v1', state: 'binding_pending', error_code: 'PARENT_JOIN_REQUIRED', connection, + tool: 'parent_join', arguments: { project, name, ...(model ? { model } : {}), ...(effort ? { effort } : {}), ...(flag === '--mission' && mission ? { mission } : {}) }, + actor_environment, role_file: existsSync(join(project, '.team', 'roles', 'parent.md')) ? join(project, '.team', 'roles', 'parent.md') : null } +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { console.log(JSON.stringify(await prepareParent(process.argv.slice(2)))) } + catch (error) { console.error(JSON.stringify({ error_code: error.code ?? 'PARENT_PREPARE_FAILED', detail: error.message })); process.exitCode = 1 } +} diff --git a/skill/scripts/parent-receive.mjs b/skill/scripts/parent-receive.mjs new file mode 100644 index 00000000..84ce16be --- /dev/null +++ b/skill/scripts/parent-receive.mjs @@ -0,0 +1,17 @@ +#!/usr/bin/env node +// 親のnative背景toolだけが起動する、1slotの構造化受信入口。 +import { ParentSpool } from './parent-delivery.mjs' +import { backgroundReceive } from './parent-receivers/background.mjs' +const [project, endpoint, waiter_id, generation] = process.argv.slice(2) +const write = value => new Promise((resolve, reject) => process.stdout.write(`${JSON.stringify(value)}\n`, error => error ? reject(error) : resolve())) +try { + const result = await backgroundReceive(new ParentSpool(project, endpoint), { waiter_id, generation }, write) + if (result.outcome === 'timeout') process.exitCode = 3 + else if (['stopped', 'already_running'].includes(result.outcome)) { + await write({ schema: 'peertable.parent-background-result.v1', endpoint_id: endpoint, ...result, error_code: result.outcome === 'stopped' ? 'PARENT_RECEIVER_STOPPED' : 'PARENT_WAIT_ALREADY_RUNNING' }) + process.exitCode = 4 + } +} catch (error) { + process.stderr.write(`${JSON.stringify({ schema: 'peertable.parent-background-result.v1', outcome: 'failed', error_code: error.code ?? 'PARENT_RECEIVE_FAILED', detail: error.message })}\n`) + process.exitCode = 1 +} diff --git a/skill/scripts/parent-receivers/background.mjs b/skill/scripts/parent-receivers/background.mjs new file mode 100644 index 00000000..348dc2c3 --- /dev/null +++ b/skill/scripts/parent-receivers/background.mjs @@ -0,0 +1,97 @@ +// 背景taskは親のnative toolが起動する。receipt作成だけではarmedにしない。 +import { randomUUID } from 'node:crypto' +import { fileURLToPath } from 'node:url' +import { setTimeout as delay } from 'node:timers/promises' +import { sameProcess, processIdentity, processDescendsFrom, shellCommand, hookCommand, windowsArgumentList, failure } from '../parent-platform.mjs' +import { PAGE_CHARS, renderDelivery } from '../parent-delivery.mjs' + +const entry = fileURLToPath(new URL('../parent-receive.mjs', import.meta.url)) +export function waitReceipt(spool) { + return spool.transact(state => { + if (state.waiter && sameProcess(state.waiter.owner)) return { ...state.wait_receipt, already_running: true, native_tool: null } + const receipt = state.wait_receipt && state.runtime === 'rearm_pending' && !state.waiter ? state.wait_receipt : { + schema: 'peertable.parent-wait-process.v1', endpoint_id: spool.id, waiter_id: randomUUID(), generation: randomUUID(), + } + receipt.executable = process.execPath + receipt.args = [entry, spool.project, spool.id, receipt.waiter_id, receipt.generation] + receipt.windows_start_process_argument_list = windowsArgumentList(receipt.args) + const command = process.platform === 'win32' ? hookCommand(receipt.executable, receipt.args) : shellCommand(receipt.executable, receipt.args) + receipt.native_tool = state.harness === 'grok' + ? { name: 'run_terminal_command', input: { command, description: 'Peertable親宛受信', background: true, timeout: 0 } } + // 通常Cursor CLIの実測input。Shellは長い待機を公式の背景taskへ移す。 + : { name: 'Shell', input: { command, cwd: spool.project } } + state.wait_receipt = receipt + return receipt + }) +} + +export async function bindNativeWait(spool, event, harness) { + const state = spool.read(), expected = state.wait_receipt?.native_tool + if (!expected) return false + const tool = harness === 'grok' ? event.toolName : event.tool_name + const input = harness === 'grok' ? event.toolInput : event.tool_input + if (tool !== expected.name || input?.command !== expected.input.command) return false + let output = harness === 'grok' ? event.toolResult : event.tool_output + if (typeof output === 'string') { try { output = JSON.parse(output) } catch { throw failure('PARENT_NATIVE_WAIT_RESULT_INVALID') } } + const task = harness === 'grok' ? output?.task_id : output?.shell_id + const pid = output?.pid + if (!task || !Number.isSafeInteger(pid)) throw failure('PARENT_NATIVE_WAIT_RESULT_INVALID') + const deadline = Date.now() + 5000 + while (!spool.read().waiter && Date.now() < deadline) await delay(50) + spool.transact(saved => { + const waiter = saved.waiter + if (!waiter || waiter.waiter_id !== saved.wait_receipt.waiter_id || waiter.generation !== saved.wait_receipt.generation || !sameProcess(waiter.owner)) throw failure('PARENT_NATIVE_WAIT_NOT_RUNNING') + // POSIXは同じNode PID。PowerShell 7は公式task PIDの子としてNodeが動く。 + const taskOwner = processIdentity(pid) + if (!taskOwner || !processDescendsFrom(waiter.owner, taskOwner)) throw failure('PARENT_NATIVE_WAIT_PROCESS_MISMATCH') + waiter.native_task = { id: String(task), pid, process_identity: taskOwner, input: expected, at: new Date().toISOString() } + saved.runtime = 'armed' + }) + await spool.publishHealth() + return true +} + +export async function backgroundReceive(spool, receipt, write) { + await spool.assertCurrentEndpoint() + const initial = spool.read() + const waiter = spool.slot(`${initial.harness}_background`, receipt) + if (!waiter) return { outcome: 'already_running' } + const deadline = Date.now() + 86400000 + try { + for (;;) { + const state = spool.read() + if (state.runtime === 'stopped' || !sameProcess(state.caller.owner)) return { outcome: 'stopped' } + // 初期登録resultが確認されるまで本文を消費しない。 + const record = state.waiter?.native_task ? spool.claim(`${state.harness}_background`) : null + if (record) { + await spool.assertCurrentEndpoint() + if (state.harness === 'grok') { + spool.handoff(record) + try { + await write({ schema: 'peertable.parent-background-result.v1', endpoint_id: spool.id, waiter_id: waiter.waiter_id, generation: waiter.generation, outcome: 'ready', delivery_id: record.delivery_id, digest: record.digest }) + } catch (error) { spool.finish(record, { state: 'unknown', reason: 'PARENT_GROK_READY_OUTPUT_UNKNOWN' }); throw error } + } else { + const preview = String(record.event.body ?? '').length > PAGE_CHARS + try { + if (preview) spool.handoff(record) + await write({ schema: 'peertable.parent-background-result.v1', endpoint_id: spool.id, waiter_id: waiter.waiter_id, generation: waiter.generation, outcome: 'ready', delivery_id: record.delivery_id, digest: record.digest, + text: renderDelivery(state, record, { preview }), complete: !preview }) + if (!preview) spool.finish(record, { reason: 'cursor_native_background_output' }) + } catch (error) { spool.finish(record, { state: 'unknown', reason: 'PARENT_BACKGROUND_OUTPUT_UNKNOWN' }); throw error } + } + await spool.flushReceiptsAfterOutput() + return { outcome: 'ready' } + } + if (Date.now() >= deadline) { + await write({ schema: 'peertable.parent-background-result.v1', endpoint_id: spool.id, outcome: 'timeout', error_code: 'PARENT_RECEIVER_EXPIRED', rearm: { tool: 'parent_read', arguments: { endpoint_id: spool.id } } }) + return { outcome: 'timeout' } + } + await delay(250) + } + } finally { + spool.releaseSlot(waiter, spool.read().runtime === 'stopped' ? 'stopped' : 'rearm_pending') + // 終了したtaskのreceiptを再利用せず、次の世代を返せる状態にする。 + spool.update({ wait_receipt: null }) + await spool.publishHealth() + } +} diff --git a/skill/scripts/parent-receivers/claude.mjs b/skill/scripts/parent-receivers/claude.mjs new file mode 100644 index 00000000..25c0949d --- /dev/null +++ b/skill/scripts/parent-receivers/claude.mjs @@ -0,0 +1,70 @@ +import { setTimeout as delay } from 'node:timers/promises' +import { mkdirSync, existsSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { sameProcess, parentHome, atomicJson, readJson, processIdentity, failure } from '../parent-platform.mjs' +import { PAGE_CHARS, renderDelivery, digest, withParentLock } from '../parent-delivery.mjs' +import { endpointsFor } from '../parent-caller.mjs' + +export async function claudeRewake(spools, write, { leaseMs = 86340000, slotRoot = join(parentHome(), 'claude-session-slots'), listEndpoints = endpointsFor } = {}) { + if (!Array.isArray(spools)) spools = [spools] + if (!spools.length) return 0 + const session = spools[0].read().caller.conversation + const root = slotRoot + mkdirSync(root, { recursive: true, mode: 0o700 }) + const slot = join(root, digest(session)) + mkdirSync(slot, { recursive: true }) + const ownerFile = join(slot, 'owner.json') + const own = processIdentity(process.pid) + const acquired = withParentLock(join(slot, 'transactions'), () => { + if (existsSync(ownerFile) && sameProcess(readJson(ownerFile))) return false + atomicJson(ownerFile, own) + return true + }) + if (!acquired) return 0 + const waiters = [] + const deadline = Date.now() + leaseMs + try { + for (const spool of spools) { const waiter = spool.slot('claude_asyncRewake'); if (waiter) waiters.push({ spool, waiter }) } + for (;;) { + for (const spool of listEndpoints({ caller: { harness: 'claude', conversation: session } })) { + if (!waiters.some(item => item.spool.id === spool.id) && spool.read().runtime !== 'stopped') { + const caller = spool.read().caller, original = spools[0].read().caller + if (caller.owner.pid !== original.owner.pid || caller.owner.started !== original.owner.started) continue + const waiter = spool.slot('claude_asyncRewake') + if (waiter) waiters.push({ spool, waiter }) + } + } + const active = waiters.filter(({ spool }) => spool.read().runtime !== 'stopped' && sameProcess(spool.read().caller.owner)) + if (!active.length) return 0 + for (const { spool } of active) { + const state = spool.read(), record = spool.claim('claude_asyncRewake') + if (record) { + const preview = String(record.event.body ?? '').length > PAGE_CHARS + try { + await spool.assertCurrentEndpoint() + if (preview) spool.handoff(record) + await write(renderDelivery(state, record, { preview })) + if (!preview) spool.finish(record, { reason: 'claude_asyncRewake_output' }) + } catch (error) { spool.finish(record, { state: 'unknown', reason: 'PARENT_CLAUDE_OUTPUT_UNKNOWN' }); throw error } + await spool.flushReceiptsAfterOutput() + return 2 + } + } + if (Date.now() >= deadline) { + const spool = active[0].spool, state = spool.read() + await write(JSON.stringify({ schema: 'peertable.parent-control.v1', code: 'PARENT_RECEIVER_EXPIRED', endpoint_id: spool.id, rearm: { tool: 'parent_join', arguments: { project: spool.project, name: state.name } } })) + return 2 + } + await delay(250) + } + } finally { + for (const { spool, waiter } of waiters) spool.releaseSlot(waiter, spool.read().runtime === 'stopped' ? 'stopped' : 'rearm_pending') + for (const { spool } of waiters) await spool.publishHealth() + withParentLock(join(slot, 'transactions'), () => { + if (existsSync(ownerFile)) { + const saved = readJson(ownerFile) + if (saved.pid === own.pid && saved.started === own.started) rmSync(ownerFile) + } + }) + } +} diff --git a/skill/scripts/parent-receivers/codex.mjs b/skill/scripts/parent-receivers/codex.mjs new file mode 100644 index 00000000..9ecd4d2c --- /dev/null +++ b/skill/scripts/parent-receivers/codex.mjs @@ -0,0 +1,150 @@ +// 公式App ServerのJSON-RPCだけを使う。Aitermの内部module/stateは参照しない。 +import { spawn } from 'node:child_process' +import { createInterface } from 'node:readline' +import { linkSync, mkdirSync, unlinkSync, realpathSync } from 'node:fs' +import { join, basename } from 'node:path' +import { randomUUID } from 'node:crypto' +import { failure, resolveExecutable, atomicJson, processIdentity, sameProcess, readJson } from '../parent-platform.mjs' +import { digest, renderDelivery } from '../parent-delivery.mjs' + +export function codexCallerOptions(caller) { + if (!sameProcess(caller.owner) || !['codex', 'codex.exe'].includes(basename(caller.owner.executable ?? '').toLowerCase()) || !caller.codex_home) throw failure('PARENT_CODEX_CALLER_EXECUTABLE_UNBOUND') + return { executable: caller.owner.executable, codexHome: caller.codex_home } +} +export async function codexConnection(project, { executable = resolveExecutable('codex'), codexHome, timeout = 15000 } = {}) { + const child = spawn(executable, ['app-server', '--listen', 'stdio://'], { cwd: project, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, ...(codexHome ? { CODEX_HOME: codexHome } : {}) } }) + const pending = new Map() + let next = 0, closed = false, stderr = '' + const rejectAll = error => { for (const call of pending.values()) { clearTimeout(call.timer); call.reject(error) }; pending.clear() } + child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-4000) }) + child.on('error', rejectAll) + child.on('close', code => { closed = true; rejectAll(failure('PARENT_CODEX_DISCONNECTED', `App Server終了 ${code}: ${stderr}`)) }) + const lines = createInterface({ input: child.stdout }) + lines.on('line', line => { + let row + try { row = JSON.parse(line) } catch { rejectAll(failure('PARENT_CODEX_PROTOCOL_INVALID')); return } + const call = pending.get(row.id) + if (!call) return + pending.delete(row.id); clearTimeout(call.timer) + if (row.error) call.reject(Object.assign(failure('PARENT_CODEX_RPC_REJECTED', JSON.stringify(row.error)), { rpc_rejected: true })) + else call.resolve(row.result) + }) + const request = (method, params) => new Promise((resolve, reject) => { + const id = ++next + const timer = setTimeout(() => { pending.delete(id); reject(failure('PARENT_CODEX_RPC_TIMEOUT', method)) }, timeout) + pending.set(id, { resolve, reject, timer }) + child.stdin.write(`${JSON.stringify({ id, method, params })}\n`, error => { if (error) { pending.delete(id); clearTimeout(timer); reject(error) } }) + }) + const close = async () => { + if (closed) return + const done = new Promise(resolve => child.once('close', resolve)) + child.stdin.end() + const timer = setTimeout(() => child.kill(), 2000) + await done; clearTimeout(timer); lines.close() + } + try { + await request('initialize', { clientInfo: { name: 'peertable-parent', version: '1' }, capabilities: { experimentalApi: true } }) + child.stdin.write(`${JSON.stringify({ method: 'initialized' })}\n`) + return { request, close } + } catch (error) { await close(); throw error } +} + +export function assertCodexParentThread(response, conversation) { + if (response.thread?.id !== conversation) throw failure('PARENT_CODEX_THREAD_MISMATCH') + const subagent = response.thread.source?.subAgent + if (subagent && typeof subagent === 'object' && 'thread_spawn' in subagent) throw failure('CODEX_PARENT_UNSUPPORTED', 'Codexのnative sub-agentは外部processからのキュー入力を受け付けません') +} +export async function checkCodexReceiver(project, caller, expectedCommand, { connect = codexConnection } = {}) { + const client = await connect(project, connect === codexConnection ? codexCallerOptions(caller) : {}) + try { + const thread = await client.request('thread/read', { threadId: caller.conversation, includeTurns: false }) + assertCodexParentThread(thread, caller.conversation) + await client.request('thread/queue/list', { threadId: caller.conversation, limit: 1 }) + const hooks = await client.request('hooks/list', { cwds: [project] }) + const entries = hooks.data?.flatMap(group => group.hooks ?? []) ?? [] + const home = caller.codex_home + const source = realpathSync(join(home, 'hooks.json')) + const owned = entries.filter(hook => hook.command === expectedCommand && realpathSync(hook.sourcePath) === source && hook.handlerType === 'command' && hook.async === false) + if (owned.length !== 2 || !['postToolUse', 'stop'].every(event => owned.some(hook => hook.eventName === event && hook.enabled === true && hook.trustStatus === 'trusted'))) throw failure('PARENT_CODEX_HOOK_UNTRUSTED') + } finally { await client.close() } +} + +export async function queueCodex(spool, { connect = codexConnection, checkTarget = () => spool.assertCurrentEndpoint() } = {}) { + await checkTarget() + const record = spool.claim('codex_queue') + if (!record) return false + let client, requestStarted = false + try { + client = await connect(spool.project, connect === codexConnection ? codexCallerOptions(spool.read().caller) : {}) + const state = spool.read(), text = renderDelivery(state, record) + spool.transact(saved => { const item = spool.owned(saved, record); item.queue_text = text; item.queue_digest = digest(text); item.queue_thread = state.caller.conversation }) + const intentDir = join(spool.root, 'queue-owned'); mkdirSync(intentDir, { recursive: true }) + atomicJson(join(intentDir, `${record.delivery_id}.json`), { delivery_id: record.delivery_id, endpoint_id: spool.id, thread: state.caller.conversation, digest: digest(text) }) + await checkTarget() + requestStarted = true + const result = await client.request('thread/queue/add', { threadId: state.caller.conversation, input: [{ type: 'text', text, text_elements: [] }], clientUserMessageId: record.delivery_id }) + if (!result.queuedSubmission?.id) throw failure('PARENT_CODEX_ACCEPTANCE_MISSING') + const current = spool.read().records.find(item => item.delivery_id === record.delivery_id) + spool.finish(record, { queued_submission_id: result.queuedSubmission.id, accepted_at: current.accepted_at ?? new Date().toISOString(), reason: 'codex_queue_accepted' }) + spool.update({ runtime: 'armed' }) + await spool.flushReceiptsAfterOutput() + return true + } catch (error) { + const current = spool.read().records.find(item => item.delivery_id === record.delivery_id) + // 明示拒否は投入不成立。後段hookがすでに所有した場合、その記録を優先する。 + if (!current.hook_claim) spool.finish(record, { state: requestStarted && !error.rpc_rejected ? 'unknown' : 'failed', reason: error.code ?? 'PARENT_CODEX_QUEUE_FAILED' }) + throw error + } finally { if (client) await client.close() } +} + +export async function codexHook(spool, event, write, { connect = codexConnection, checkTarget = () => spool.assertCurrentEndpoint() } = {}) { + const state = spool.read() + if (event.session_id !== state.caller.conversation) return false + await checkTarget() + const client = await connect(spool.project, connect === codexConnection ? codexCallerOptions(state.caller) : {}) + try { + let after = null, records = [] + do { + const result = await client.request('thread/queue/list', { threadId: state.caller.conversation, limit: 100, ...(after ? { cursor: after } : {}) }) + records.push(...(result.data ?? [])); after = result.nextCursor ?? null + } while (after) + for (const queued of records) { + const saved = spool.read().records.find(record => record.delivery_id === queued.clientUserMessageId && (!record.queued_submission_id || record.queued_submission_id === queued.id) && record.queue_thread === state.caller.conversation && ['sending', 'submitted', 'unknown'].includes(record.state)) + if (!saved) continue + const text = queued.input?.filter(input => input.type === 'text').map(input => input.text).join('') + if (digest(text ?? '') !== saved.queue_digest) throw failure('PARENT_CODEX_QUEUE_BODY_MISMATCH') + const intent = readJson(join(spool.root, 'queue-owned', `${saved.delivery_id}.json`)) + if (intent.thread !== state.caller.conversation || intent.digest !== saved.queue_digest || intent.endpoint_id !== spool.id) throw failure('PARENT_CODEX_QUEUE_OWNER_MISMATCH') + const claimRoot = join(spool.root, 'queue-claims'); mkdirSync(claimRoot, { recursive: true }) + const claim = { ...intent, id: randomUUID(), owner: processIdentity(process.pid), at: new Date().toISOString() } + const candidate = join(claimRoot, `${saved.delivery_id}.${claim.id}.candidate.json`) + atomicJson(candidate, claim) + try { linkSync(candidate, join(claimRoot, `${saved.delivery_id}.json`)) } + catch (error) { if (error.code === 'EEXIST') continue; throw error } + finally { unlinkSync(candidate) } + const record = spool.transact(current => { + const item = current.records.find(item => item.delivery_id === saved.delivery_id) + if (item.hook_claim) return null + item.hook_claim = claim + item.hook_state = 'claimed' + item.queued_submission_id = queued.id + item.accepted_at ??= new Date().toISOString() + return item + }) + if (!record) continue + try { + const removed = await client.request('thread/queue/delete', { threadId: state.caller.conversation, queuedSubmissionId: queued.id }) + if (removed.deleted !== true) throw failure('PARENT_CODEX_DELETE_UNCONFIRMED') + spool.transact(current => { const item = spool.owned(current, record); item.hook_state = 'deleted'; item.hook_turn = event.turn_id }) + await checkTarget() + await write(event.hook_event_name === 'Stop' ? { decision: 'block', reason: text } : { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: text } }) + spool.transact(current => { const saved = spool.owned(current, record); saved.hook_state = 'output_complete' }) + const complete = spool.read().records.find(item => item.delivery_id === record.delivery_id) + spool.finish(record, { queued_submission_id: queued.id, accepted_at: complete.accepted_at, reason: 'codex_hook_output_complete', resolve_unknown: true }) + } catch (error) { spool.finish(record, { state: 'unknown', reason: error.code ?? 'PARENT_CODEX_HOOK_OUTPUT_UNKNOWN' }); throw error } + await spool.flushReceiptsAfterOutput() + return true + } + return false + } finally { await client.close() } +} diff --git a/skill/scripts/parent-receivers/cursor.mjs b/skill/scripts/parent-receivers/cursor.mjs new file mode 100644 index 00000000..a4a4357e --- /dev/null +++ b/skill/scripts/parent-receivers/cursor.mjs @@ -0,0 +1,23 @@ +import { renderDelivery, PAGE_CHARS } from '../parent-delivery.mjs' +export { waitReceipt, bindNativeWait, backgroundReceive } from './background.mjs' +export async function cursorEvent(spool, event, write, options = {}) { + if (!['afterMCPExecution', 'postToolUse'].includes(event.hook_event_name)) return false + // afterMCPExecutionのtool_input/result_jsonは実測上JSON文字列。native登録はpostToolUseが所有する。 + if (event.hook_event_name === 'postToolUse') await (await import('./background.mjs')).bindNativeWait(spool, event, 'cursor') + return cursorHook(spool, write, options) +} + +export async function cursorHook(spool, write, { checkTarget = () => spool.assertCurrentEndpoint() } = {}) { + await checkTarget() + const record = spool.claim('cursor_additional_context') + if (!record) return false + const preview = String(record.event.body ?? '').length > PAGE_CHARS + try { + await checkTarget() + if (preview) spool.handoff(record) + await write({ additional_context: renderDelivery(spool.read(), record, { preview }) }) + if (!preview) spool.finish(record, { reason: 'cursor_hook_output' }) + } catch (error) { spool.finish(record, { state: 'unknown', reason: 'PARENT_CURSOR_OUTPUT_UNKNOWN' }); throw error } + await spool.flushReceiptsAfterOutput() + return true +} diff --git a/skill/scripts/parent-receivers/grok.mjs b/skill/scripts/parent-receivers/grok.mjs new file mode 100644 index 00000000..c4b035f0 --- /dev/null +++ b/skill/scripts/parent-receivers/grok.mjs @@ -0,0 +1,2 @@ +// Grokはnative背景完了とparent_readによる本文返却で受付を確定する。 +export { waitReceipt, bindNativeWait, backgroundReceive } from './background.mjs' diff --git a/skill/scripts/parent-runtime.mjs b/skill/scripts/parent-runtime.mjs new file mode 100644 index 00000000..9ac760d3 --- /dev/null +++ b/skill/scripts/parent-runtime.mjs @@ -0,0 +1,166 @@ +// 親runtimeの起動・移行・診断・停止。親harnessそのものは停止しない。 +import { existsSync, readFileSync, openSync, closeSync, rmSync, readdirSync, realpathSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { spawn, execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { setTimeout as delay } from 'node:timers/promises' +import { randomUUID } from 'node:crypto' +import { ParentSpool } from './parent-delivery.mjs' +import { atomicJson, readJson, processIdentity, sameProcess, failure, posixQuote, psQuote } from './parent-platform.mjs' +import { registerEndpoint, endpointsFor } from './parent-caller.mjs' +import { RoomApi } from './room-api.mjs' +import { runtimeDigest } from './runtime-digest.mjs' + +const watcherEntry = fileURLToPath(new URL('./parent-watch.mjs', import.meta.url)) +export const setupFor = project => readJson(join(project, '.team', 'setup-state.json')) +export function parentMemberRecord(name, harness, endpoint_id, display = {}) { + if (!['claude', 'codex', 'grok', 'cursor'].includes(harness) || !/^[0-9a-f-]{36}$/u.test(endpoint_id ?? '')) throw failure('PARENT_MEMBER_BINDING_REQUIRED') + return { name, roles: ['統括'], harness, ...display, delivery: { kind: 'parent_receiver', harness, endpoint_id } } +} +export function actorEnvironment(project, name) { + if (setupFor(project).mode !== 'lattice') return null + const values = { LATTICE_TODO_ACTOR_HOST: { darwin: 'mac', linux: 'linux', win32: 'windows' }[process.platform], LATTICE_TODO_ACTOR_SESSION: name, LATTICE_TODO_ACTOR_AGENT: name } + atomicJson(join(project, '.team', 'parent-env.json'), values) + const file = join(project, '.team', process.platform === 'win32' ? 'parent-env.ps1' : 'parent-env.sh') + writeFileSync(file, Object.entries(values).map(([key, value]) => process.platform === 'win32' ? `$env:${key} = ${psQuote(value)}` : `export ${key}=${posixQuote(value)}`).join('\n') + '\n', { mode: 0o600 }) + return { file, command: `. ${process.platform === 'win32' ? psQuote(file) : posixQuote(file)}` } +} +export function credentialFor(project, state, name) { + if (process.env.PEERTABLE_CREDENTIAL_FILE) return process.env.PEERTABLE_CREDENTIAL_FILE + return execFileSync(process.execPath, [fileURLToPath(new URL('./seat-credential.mjs', import.meta.url)), 'prepare', project, state.room, name], { encoding: 'utf8' }).trim() +} +export function projectEndpoints(project) { + const dir = join(project, '.team', 'parent-delivery') + if (!existsSync(dir)) return [] + return readdirSync(dir).filter(id => /^[0-9a-f-]{36}$/u.test(id)).map(id => new ParentSpool(project, id)) +} +export async function stopOwnedProcess(owner) { + if (!owner || !sameProcess(owner)) return + process.kill(owner.pid, 'SIGTERM') + const deadline = Date.now() + 3000 + while (Date.now() < deadline && sameProcess(owner)) await delay(50) + if (sameProcess(owner)) throw failure('PARENT_RECEIVER_STOP_FAILED', `受信process ${owner.pid}が停止しません`) +} +function hasSharedWaiter(spool, state) { + return state.waiter?.channel === 'claude_asyncRewake' && endpointsFor().some(other => { + if (other.id === spool.id) return false + const saved = other.read() + return saved.runtime !== 'stopped' && saved.waiter?.owner?.pid === state.waiter.owner.pid && saved.waiter?.owner?.started === state.waiter.owner.started + }) +} +export async function stopEndpoint(spool) { + const state = spool.update({ runtime: 'stopped', closed_at: new Date().toISOString() }) + const sharedWaiter = hasSharedWaiter(spool, state) + for (const owner of [sharedWaiter ? null : state.waiter?.owner, state.watcher]) await stopOwnedProcess(owner) + spool.update({ waiter: null, watcher: null }) + spool.transact(saved => { + for (const record of saved.records) if (record.state === 'ready' || record.state === 'waiting') { + record.state = 'failed'; record.error_code = 'PARENT_SESSION_CLOSED'; record.receipt = spool.receiptFor(saved, record, 'failed', record.error_code) + } + }) +} +export async function migrateLegacy(spool, api) { + const file = join(spool.project, '.team', 'parent-watch.json') + if (!existsSync(file)) return + const legacy = readJson(file), state = spool.read() + if (legacy.room !== state.room || legacy.server_url.replace(/\/$/u, '') !== state.server_url || legacy.parent !== state.name || !Number.isSafeInteger(legacy.last_seq)) throw failure('PARENT_LEGACY_CURSOR_INVALID') + const lock = join(spool.project, '.team', 'parent-watch.lock') + if (existsSync(lock)) { + const pid = Number(readFileSync(lock, 'utf8').trim()) + const owner = processIdentity(pid) + if (owner) { + if (!owner.command.includes(watcherEntry) || !owner.command.includes(spool.project)) throw failure('PARENT_LEGACY_OWNER_UNKNOWN') + await stopOwnedProcess(owner) + } + rmSync(lock, { force: true }) + } + const { messages } = await api.request('messages') + const missingEvidence = [] + for (const message of messages) { + if (message.from === state.name || !(message.to === 'all' || message.to === state.name || message.to_names?.includes(state.name))) continue + const { delivery } = await api.request(`deliveries?seq=${message.seq}`) + const receipt = delivery?.[state.name] + if (receipt?.state === 'delivered' && receipt.reason === 'parent_watch') { + const record = spool.saveEvent({ schema: 'peertable.parent-watch-event.v1', room: state.room, type: message.to === 'all' ? 'parent_room_update' : 'parent_dm', parent: state.name, ...message, message }) + spool.transact(saved => { const item = saved.records.find(item => item.delivery_id === record.delivery_id); item.state = 'submitted'; item.migration_receipt = receipt; item.accepted_at = receipt.at }) + } else if (message.seq <= legacy.last_seq) missingEvidence.push(message.seq) + } + spool.update({ cursor: legacy.last_seq, source_state: { ...legacy }, migration: { legacy_cursor: legacy.last_seq, insufficient_evidence_seqs: missingEvidence, status: missingEvidence.length ? 'evidence_missing' : 'verified', stopped_at: new Date().toISOString() } }) +} +export async function startEndpoint(spool) { + const state = spool.recover() + if (!sameProcess(state.caller.owner)) throw failure('PARENT_SESSION_NOT_RUNNING') + if (!Number.isSafeInteger(state.cursor)) throw failure('PARENT_CURSOR_MISSING') + const currentDigest = runtimeDigest() + if (state.watcher && sameProcess(state.watcher) && state.runtime_digest === currentDigest) return + if (state.watcher && sameProcess(state.watcher)) await stopOwnedProcess(state.watcher) + if (state.waiter && sameProcess(state.waiter.owner) && state.runtime_digest !== currentDigest) { + // 同じClaude sessionの別endpointが使うwaiterは更新時も停止しない。 + if (hasSharedWaiter(spool, state)) throw failure('PARENT_RESTART_REQUIRED', '共有待機の読込sourceを同じ親sessionで更新してください') + await stopOwnedProcess(state.waiter.owner) + } + spool.update({ watcher: null, runtime_digest: currentDigest }) + const fd = openSync(join(spool.root, 'watch.log'), 'a', 0o600) + try { + spool.transact(saved => { + if (saved.watcher && sameProcess(saved.watcher)) return + const child = spawn(process.execPath, [watcherEntry, spool.project, state.name, '--deliver', spool.id], { + cwd: spool.project, detached: process.platform !== 'win32', stdio: ['ignore', fd, fd], + env: { ...process.env, PEERTABLE_CREDENTIAL_FILE: state.credential, PEERTABLE_WATCH_NO_STDIN: '1' }, + }) + child.on('error', error => { spool.update({ runtime: 'failed', error_code: 'PARENT_WATCH_START_FAILED', error_detail: error.message }) }) + child.unref() + const identity = processIdentity(child.pid) + if (!identity) throw failure('PARENT_WATCH_START_FAILED') + saved.watcher = identity + }) + } finally { closeSync(fd) } + const probe = spool.read().probe_id ?? randomUUID() + spool.update({ probe_id: probe }) + spool.saveEvent({ schema: 'peertable.parent-watch-event.v1', type: 'parent_probe', event_id: `probe:${probe}`, room: state.room, from: 'peertable', to: state.name, body: `配送確認の符号は ${probe}。` }) +} +export async function joinEndpoint(projectArg, name, caller, display = {}) { + const project = realpathSync(projectArg), setup = setupFor(project) + const api = new RoomApi(setup, { credential: credentialFor(project, setup, name) }) + const previous = projectEndpoints(project).find(spool => { const saved = spool.read(); return saved.name === name && saved.caller.harness === caller.harness && saved.caller.conversation === caller.conversation }) + let spool = previous + if (spool) { + if (!Number.isSafeInteger(spool.read().cursor)) throw failure('PARENT_CURSOR_MISSING') + spool.update({ caller, runtime: spool.read().waiter && sameProcess(spool.read().waiter.owner) ? spool.read().runtime : 'rearm_pending', state: spool.read().state === 'verified' ? 'verified' : 'receiving', probe_deadline: Date.now() + 30000, error_code: null }) + } else { + for (const old of projectEndpoints(project).filter(item => item.read().name === name && item.read().runtime !== 'stopped')) await stopEndpoint(old) + const summary = await api.request('summary') + if (!Number.isSafeInteger(summary.seq)) throw failure('PARENT_START_SEQ_INVALID') + spool = ParentSpool.create(project, { name, harness: caller.harness, caller, room: setup.room, server_url: setup.server_url.replace(/\/$/u, ''), start_seq: summary.seq, credential: api.credential }) + registerEndpoint(spool) + if ((await api.members()).some(member => member.name === name && member.delivery?.kind === 'parent_watch')) await migrateLegacy(spool, api) + } + await api.request('members', { method: 'POST', body: parentMemberRecord(name, caller.harness, spool.id, display) }) + actorEnvironment(project, name) + await startEndpoint(spool) + return spool +} +export async function parentRuntimeStatus(project, { restart = false } = {}) { + const api = new RoomApi(setupFor(project)) + const parents = (await api.members()).filter(member => ['parent_receiver', 'parent_watch'].includes(member.delivery?.kind)) + const endpoints = projectEndpoints(project) + const checks = parents.length ? [] : [{ name: null, status: 'failed', error_code: 'PARENT_JOIN_REQUIRED', action: { tool: 'parent_join', arguments: { project } } }] + for (const member of parents) { + if (member.delivery.kind === 'parent_watch') { checks.push({ name: member.name, status: 'failed', error_code: 'PARENT_LEGACY_RECEIVER', action: 'parent_join' }); continue } + const spool = endpoints.find(item => item.id === member.delivery.endpoint_id) + if (!spool) { checks.push({ name: member.name, status: 'failed', error_code: 'PARENT_ENDPOINT_NOT_FOUND' }); continue } + try { + let state = spool.recover() + if (state.caller.peertable_source_digest !== runtimeDigest()) throw failure('PARENT_RESTART_REQUIRED', '親MCPの読込sourceと現在のpackageが異なります') + if (restart && sameProcess(state.caller.owner) && state.runtime !== 'stopped') { await startEndpoint(spool); state = spool.read() } + const live = sameProcess(state.caller.owner) && sameProcess(state.watcher) && state.runtime_digest === runtimeDigest() + const armed = state.runtime === 'armed' && (state.harness === 'codex' || sameProcess(state.waiter?.owner)) + const unknown = state.records.filter(record => record.state === 'unknown').map(record => record.delivery_id) + const failed = state.records.filter(record => record.state === 'failed').map(record => record.delivery_id) + const missingEvidence = state.migration?.status === 'evidence_missing' + checks.push({ name: member.name, endpoint_id: spool.id, state: state.state, runtime: state.runtime, status: live && armed && state.state === 'verified' && !state.receipt_error && !state.health_error && !unknown.length && !failed.length && !missingEvidence ? 'ready' : 'failed', + error_code: state.error_code ?? (!live ? 'PARENT_SESSION_NOT_RUNNING' : !armed ? 'PARENT_REARM_REQUIRED' : state.state !== 'verified' ? 'PARENT_PROBE_PENDING' : state.receipt_error ? 'PARENT_RECEIPT_FAILED' : state.health_error ? 'PARENT_HEALTH_REPORT_FAILED' : unknown.length ? 'PARENT_DELIVERY_UNKNOWN' : failed.length ? 'PARENT_DELIVERY_FAILED' : missingEvidence ? 'PARENT_LEGACY_EVIDENCE_MISSING' : null), unknown, failed, migration: state.migration }) + } catch (error) { checks.push({ name: member.name, status: 'failed', error_code: error.code, detail: error.message }) } + } + return { schema: 'peertable.parent-runtime.v1', status: checks.some(check => check.status !== 'ready') ? 'failed' : 'ready', endpoints: checks } +} diff --git a/skill/scripts/parent-source-runtime.test.mjs b/skill/scripts/parent-source-runtime.test.mjs new file mode 100644 index 00000000..ddf5a799 --- /dev/null +++ b/skill/scripts/parent-source-runtime.test.mjs @@ -0,0 +1,76 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createServer } from 'node:http' +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { setTimeout as delay } from 'node:timers/promises' +import { ParentSpool } from './parent-delivery.mjs' +import { atomicJson, processIdentity } from './parent-platform.mjs' + +const waitFor = async condition => { + const deadline = Date.now() + 30000 + while (!condition()) { if (Date.now() >= deadline) throw new Error('観測期限を超過しました'); await delay(20) } +} +test('SSEが正常で新seqが無くてもreceipt retry/孤児回収/probe期限/healthが進む', async t => { + const project = mkdtempSync(join(tmpdir(), 'peertable-source-runtime-')) + t.after(() => rmSync(project, { recursive: true, force: true })) + let spool, eventsConnected = false, receiptFailures = 0 + const receipts = [], health = [], streams = new Set() + const server = createServer(async (req, res) => { + let raw = ''; for await (const chunk of req) raw += chunk + const json = value => { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(value)) } + if (req.url.endsWith('/events')) { + res.writeHead(200, { 'content-type': 'text/event-stream' }); streams.add(res); eventsConnected = true + // 新しいroom messageは全試験中ゼロ。pingのheadもゼロのまま。 + const timer = setInterval(() => res.write('event: ping\ndata: 0\n\n'), 50) + res.on('close', () => { clearInterval(timer); streams.delete(res) }); return + } + if (req.url.endsWith('/members')) return json({ members: [{ name: 'parent', delivery: { kind: 'parent_receiver', endpoint_id: spool.id } }] }) + if (req.url.includes('/messages')) return json({ messages: [] }) + if (req.url.endsWith('/summary')) return json({ seq: 0 }) + if (req.url.endsWith('/bridges')) { health.push(JSON.parse(raw)); return json({ ok: true }) } + if (req.url.endsWith('/deliveries')) { + if (receiptFailures > 0) { receiptFailures--; res.statusCode = 503; return json({ error: 'fixture_offline' }) } + receipts.push(JSON.parse(raw)); return json({ ok: true }) + } + res.statusCode = 404; json({ error: 'fixture_path_unknown' }) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + t.after(async () => { for (const stream of streams) stream.destroy(); await new Promise(resolve => server.close(resolve)) }) + const base = `http://127.0.0.1:${server.address().port}`, credential = join(project, 'credential') + writeFileSync(credential, 'fixture', { mode: 0o600 }) + spool = ParentSpool.create(project, { name: 'parent', room: 'fixture', server_url: base, credential, start_seq: 0, harness: 'cursor', caller: { conversation: 'fixture', owner: processIdentity(process.pid) } }) + atomicJson(join(project, '.team', 'setup-state.json'), { room: 'fixture', server_url: base }) + spool.update({ state: 'verified', runtime: 'armed' }) + const watcher = spawn(process.execPath, [fileURLToPath(new URL('./parent-watch.mjs', import.meta.url)), project, 'parent', '--deliver', spool.id], { stdio: ['ignore', 'ignore', 'pipe'] }) + let stderr = ''; watcher.stderr.on('data', chunk => { stderr += chunk }) + t.after(async () => { if (watcher.exitCode === null) { watcher.kill(); await once(watcher, 'exit') } }) + await waitFor(() => eventsConnected && health.length > 0) + const sent = spool.saveEvent({ type: 'parent_dm', seq: 1, body: '出力済み本文' }), claimed = spool.claim('fixture', sent.delivery_id) + spool.finish(claimed) + receiptFailures = 1 + await spool.flushReceiptsAfterOutput() + assert.equal(spool.read().records.find(record => record.delivery_id === sent.delivery_id).receipt.pending, true) + assert.equal(spool.read().receipt_error.code, 'PEERTABLE_ROOM_REQUEST_FAILED') + await waitFor(() => spool.read().records.find(record => record.delivery_id === sent.delivery_id).receipt.pending === false) + assert.ok(receipts.some(receipt => receipt.seq === 1 && receipt.result === 'delivered')) + assert.equal(spool.read().receipt_error, null) + // 実childがclaimとwaiterを保存して終了。room新投稿なしでunknownとrearmへ回収する。 + const orphan = spool.saveEvent({ type: 'parent_dm', seq: 2, body: '保持する本文' }) + const code = `import {ParentSpool} from ${JSON.stringify(new URL('./parent-delivery.mjs', import.meta.url).href)};const s=new ParentSpool(process.argv[1],process.argv[2]);s.claim('fixture',process.argv[3]);s.slot('cursor_background');s.update({runtime:'armed'});` + const child = spawn(process.execPath, ['--input-type=module', '-e', code, project, spool.id, orphan.delivery_id], { stdio: 'ignore' }) + const [exit] = await once(child, 'exit'); assert.equal(exit, 0) + await waitFor(() => receipts.some(receipt => receipt.seq === 2 && receipt.result === 'unknown') && health.some(item => item.state === 'rearm_pending')) + assert.equal(spool.read().records.find(record => record.delivery_id === orphan.delivery_id).event.body, '保持する本文') + const lastRevision = spool.read().records.find(record => record.delivery_id === orphan.delivery_id).receipt.receipt_revision + await delay(150) + assert.equal(spool.read().records.find(record => record.delivery_id === orphan.delivery_id).receipt.receipt_revision, lastRevision) + spool.update({ state: 'receiving', probe_deadline: Date.now() + 100 }) + await waitFor(() => spool.read().error_code === 'PARENT_PROBE_TIMEOUT' && health.some(item => item.state === 'failed' && JSON.parse(item.detail).error_code === 'PARENT_PROBE_TIMEOUT')) + assert.equal(spool.read().cursor, 0) + assert.equal(watcher.exitCode, null, stderr) +}) diff --git a/skill/scripts/parent-watch.mjs b/skill/scripts/parent-watch.mjs index 90f631a5..771fc500 100644 --- a/skill/scripts/parent-watch.mjs +++ b/skill/scripts/parent-watch.mjs @@ -10,23 +10,30 @@ import { execFileSync } from 'node:child_process' import { existsSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { resolveLatticeExecutable, resolvePostToken, parentWatchShouldNotify } from './seat-usage.mjs' +import { resolveLatticeExecutable, resolvePostToken } from './seat-usage.mjs' import { addressedToParent as messageAddressedToParent, latticeStaffingChanged, tableStallUpdate } from './parent-watch-logic.mjs' +import { ParentSpool, digest } from './parent-delivery.mjs' +import { RoomApi } from './room-api.mjs' +import { queueCodex } from './parent-receivers/codex.mjs' +import { isParentMember } from '../../room/parent-kind.mjs' +import { processIdentity, sameProcess, failure, atomicJson } from './parent-platform.mjs' const args = process.argv.slice(2) const project = args.shift() let parent = 'bell' if (args[0] && !args[0].startsWith('--')) parent = args.shift() const mode = args.shift() ?? '--follow' -if (!project || !['--prime', '--poll', '--next', '--follow'].includes(mode) || args.length > 0) { +const endpointId = mode === '--deliver' ? args.shift() : null +if (!project || !['--prime', '--poll', '--next', '--follow', '--deliver'].includes(mode) || args.length > 0) { console.error('usage: parent-watch.mjs [parent_name] <--prime|--poll|--next|--follow>') process.exit(2) } const team = join(project, '.team') const setupPath = join(team, 'setup-state.json') -const statePath = join(team, 'parent-watch.json') -const lockPath = join(team, 'parent-watch.lock') +const spool = endpointId ? new ParentSpool(project, endpointId) : null +const statePath = spool ? join(spool.root, 'source.json') : join(team, 'parent-watch.json') +const lockPath = spool ? join(spool.root, 'source.lock') : join(team, 'parent-watch.lock') const setup = JSON.parse(readFileSync(setupPath, 'utf8')) const room = setup.room const serverUrl = setup.server_url.replace(/\/$/u, '') @@ -34,7 +41,6 @@ const latticeCli = setup.lattice_cli || process.env.LATTICE_CLI || 'lattice' const api = `${serverUrl}/api/${encodeURIComponent(room)}` const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) const now = () => new Date().toISOString() -const ROOM_UPDATE_FALLBACK = 'room全体の状況が更新された。roomログを読み、状況を把握して次の行動を判断する。' const staffingBody = ({ ready, active }) => `現在、着手可能工程は ${ready} 件、着手中工程は ${active} 件になりました。標準は ${ready + active}+監査担当数です。円卓メンバー数を検討してください。` function readLatticeState(previous = null) { @@ -73,6 +79,7 @@ function loadState() { } function saveState(state) { + if (spool) spool.saveCursor(state.last_seq, state) const temp = `${statePath}.${process.pid}.tmp` writeFileSync(temp, `${JSON.stringify(state)}\n`, { mode: 0o600 }) renameSync(temp, statePath) @@ -85,7 +92,7 @@ async function readJson(path) { } async function ensurePrimed() { - const saved = loadState() + const saved = spool?.read().source_state ?? loadState() if (saved) { let lattice = saved.lattice if (!lattice?.source) { @@ -101,7 +108,8 @@ async function ensurePrimed() { saveState(next) return next } - const summary = await readJson('/summary') + const summary = spool ? { seq: spool.read().cursor } : await readJson('/summary') + if (spool && !Number.isSafeInteger(summary.seq)) throw new Error('PARENT_CURSOR_MISSING') const observed = readLatticeState() const state = { schema: 'peertable.parent-watch-state.v1', @@ -132,6 +140,15 @@ function stopPid(pid) { } } function acquireLock() { + if (spool) { + const own = processIdentity(process.pid) + spool.transact(saved => { + if (saved.watcher && saved.watcher.pid !== own.pid && sameProcess(saved.watcher)) throw failure('PARENT_SOURCE_ALREADY_RUNNING') + saved.watcher = own + atomicJson(lockPath, own) + }) + return + } try { writeFileSync(lockPath, `${process.pid}\n`, { flag: 'wx', mode: 0o600 }) } catch (error) { @@ -148,6 +165,17 @@ function acquireLock() { } } function releaseLock() { + if (spool) { + try { + spool.transact(saved => { + if (saved.watcher?.pid !== process.pid) return + saved.watcher = null + if (saved.runtime !== 'stopped') { saved.runtime = 'failed'; saved.error_code ??= 'PARENT_SOURCE_EXITED' } + if (existsSync(lockPath)) unlinkSync(lockPath) + }) + } catch (error) { process.stderr.write(`${error.code ?? 'PARENT_SOURCE_RELEASE_FAILED'}: ${error.message}\n`) } + return + } try { if (Number(readFileSync(lockPath, 'utf8').trim()) === process.pid) unlinkSync(lockPath) } catch {} @@ -172,10 +200,17 @@ function exitWhenParentStdinCloses() { } async function logQuiet(event) { + if (spool) spool.saveEvent({ ...event, event_id: digest(event) }, { quiet: true }) process.stderr.write(`[quiet] ${JSON.stringify(event)}\n`) } async function writeEvent(event) { + if (spool) { + await spool.assertCurrentEndpoint() + const saved = spool.saveEvent({ ...event, room, event_id: event.event_id ?? (Number.isSafeInteger(event.seq) ? `room:${event.seq}` : digest(event)) }) + if (spool.read().harness === 'codex' && saved?.state === 'ready') await queueCodex(spool) + return + } await new Promise((resolve, reject) => { process.stdout.write(`${JSON.stringify(event)}\n`, error => error ? reject(error) : resolve()) }) @@ -187,7 +222,6 @@ async function acceptLatticeState(next) { if (next.error !== undefined) { if (previous?.error === next.error && previous.source === next.source) return false state = { ...state, lattice: { ...previous, ...next }, last_event_at: now() } - saveState(state) await writeEvent({ schema: 'peertable.parent-watch-event.v1', type: 'parent_lattice_error', @@ -195,12 +229,12 @@ async function acceptLatticeState(next) { code: next.error, body: 'Lattice工程状態を取得できませんでした。親番犬のroom追従は継続します。', }) + saveState(state) return true } const changed = latticeStaffingChanged(previous, next) state = { ...state, lattice: next, last_event_at: now() } - saveState(state) - if (!changed) return false + if (!changed) { saveState(state); return false } await logQuiet({ schema: 'peertable.parent-watch-event.v1', type: 'parent_lattice_update', @@ -210,6 +244,7 @@ async function acceptLatticeState(next) { standard_worker_count: next.ready + next.active, body: staffingBody(next), }) + saveState(state) return true } @@ -236,8 +271,7 @@ async function acceptMessage(message) { const matched = addressedToParent(message) if (matched) { const roomUpdate = message.to === 'all' - const emit = parentWatchShouldNotify(message, roomUpdate) ? writeEvent : logQuiet - await emit({ + await writeEvent({ schema: 'peertable.parent-watch-event.v1', type: roomUpdate ? 'parent_room_update' : 'parent_dm', parent, @@ -245,7 +279,7 @@ async function acceptMessage(message) { from: message.from, to: message.to ?? null, to_names: message.to_names ?? null, - body: roomUpdate ? (message.body || ROOM_UPDATE_FALLBACK) : message.body, + body: message.body, message, }) // 親宛の名指しDMは、この中継(writeEvent成功=親の通知ストリームへ入った)をもって配達成立とし、 @@ -253,7 +287,7 @@ async function acceptMessage(message) { // これが無いと送り手の席から親宛DMが永遠に pending に見え、報告の再記録ループで // 席のターンを燃やし続ける(実被弾: mio が報告のたびに「bell配達pending」を再送)。 // to:"all" は席側配達(wakeup-bridge)が receipt を所有するので書かない。 - if (!roomUpdate) await postParentReceipt(message.seq) + if (!spool) await postParentReceipt(message.seq) } state = { ...state, last_seq: message.seq, last_event_at: now() } saveState(state) @@ -270,7 +304,7 @@ async function checkStall() { let members try { members = (await readJson('/members')).members ?? [] } catch { return false } const workers = members - .filter(m => m.name !== parent && m.delivery?.kind !== 'parent_watch') + .filter(m => m.name !== parent && !isParentMember(m)) .map(m => ({ name: m.name, status: m.status ?? null })) // 全席idleでも、各claim保有席に有効な待機宣言があるなら正当な外部待ちであり停滞ではない // (2026-08-25 オーナー裁定の系: 巡回番犬が起こす対象ゼロの夜に、親へ停滞警報を鳴らし続けるのは @@ -305,18 +339,44 @@ async function checkStall() { ) if (JSON.stringify(stall) !== JSON.stringify(state.stall ?? null)) { state = { ...state, stall, last_event_at: event ? now() : state.last_event_at } - saveState(state) } - if (!event) return false + if (!event) { saveState(state); return false } await writeEvent({ schema: 'peertable.parent-watch-event.v1', parent, ...event, }) + saveState(state) return true } +async function maintainReceiver() { + if (spool) { + if (spool.read().runtime === 'stopped') process.exit(0) + await spool.assertCurrentEndpoint() + if (!sameProcess(spool.read().caller.owner)) { + spool.transact(saved => { + saved.runtime = 'stopped'; saved.error_code = 'PARENT_SESSION_CLOSED' + for (const record of saved.records) if (record.state === 'ready' || record.state === 'waiting') { + record.state = 'failed'; record.error_code = saved.error_code + record.receipt = spool.receiptFor(saved, record, 'failed', record.error_code) + } + }) + await spool.flushReceiptsAfterOutput() + await spool.publishHealth() + process.exit(0) + } + spool.recover() + try { await spool.flushReceipts(new RoomApi(setup, { credential: spool.read().credential })) } + catch (error) { process.stderr.write(`${error.code}: ${error.message}\n`) } + if (spool.read().harness === 'codex') while (await queueCodex(spool)) {} + const status = spool.read() + if (status.state !== 'verified' && Date.now() > (status.probe_deadline ?? Date.parse(status.created_at) + 30000)) spool.update({ state: 'failed', runtime: 'failed', error_code: 'PARENT_PROBE_TIMEOUT' }) + await spool.publishHealth() + } +} async function catchUp() { + await maintainReceiver() if (await acceptLatticeState(readLatticeState(state.lattice)) && mode === '--next') return true if (await checkStall() && mode === '--next') return true const body = await readJson(`/messages?since=${state.last_seq}`) @@ -345,7 +405,7 @@ for (;;) { const deadline = mode === '--next' ? Date.now() + nextWindowMs : Number.POSITIVE_INFINITY try { if (await catchUp()) process.exit(0) - if (mode === '--follow' && !snapshotSent) { + if (['--follow', '--deliver'].includes(mode) && !snapshotSent) { snapshotSent = true const lattice = state.lattice await writeEvent({ @@ -368,19 +428,25 @@ for (;;) { if (!response.ok) throw new Error(`events ${response.status}`) consecutiveFailures = 0 let buffer = '' + const decoder = new TextDecoder() for await (const chunk of response.body) { - buffer += Buffer.from(chunk).toString('utf8') + buffer += decoder.decode(chunk, { stream: true }) const frames = buffer.split('\n\n') buffer = frames.pop() for (const frame of frames) { const lines = frame.split('\n') const eventName = lines.find(line => line.startsWith('event: '))?.slice(7).trim() const data = lines.filter(line => line.startsWith('data: ')).map(line => line.slice(6)).join('\n') + if (spool && eventName === 'member') { await spool.assertCurrentEndpoint(); continue } if (eventName === 'ping') { const head = Number(data) + await maintainReceiver() if (await acceptLatticeState(readLatticeState(state.lattice)) && mode === '--next') process.exit(0) if (await checkStall() && mode === '--next') process.exit(0) - if (Number.isSafeInteger(head) && head > state.last_seq && await catchUp()) process.exit(0) + if (Number.isSafeInteger(head) && head > state.last_seq) { + const body = await readJson(`/messages?since=${state.last_seq}`) + for (const message of body.messages ?? []) if (await acceptMessage(message) && mode === '--next') process.exit(0) + } continue } if (eventName !== undefined && eventName !== 'message') continue @@ -393,6 +459,7 @@ for (;;) { if (timer !== null) clearTimeout(timer) } } catch (error) { + if (error.code === 'PARENT_ENDPOINT_SUPERSEDED') process.exit(0) if (mode === '--next' && error.name === 'AbortError' && Date.now() >= deadline) process.exit(0) consecutiveFailures += 1 if (consecutiveFailures >= 10) { diff --git a/skill/scripts/platform/windows/parent-member-json.mjs b/skill/scripts/platform/windows/parent-member-json.mjs index a645e338..1c1e21fa 100644 --- a/skill/scripts/platform/windows/parent-member-json.mjs +++ b/skill/scripts/platform/windows/parent-member-json.mjs @@ -1,14 +1,11 @@ -export function parentMember(name, model, effort, harness, mission) { -const body = { name, roles: ['統括'], observe: null, delivery: { kind: 'parent_watch', host: harness || '' } } -if (harness) { body.harness = harness; body.vendor = harness } -if (model) body.model = model -if (effort) body.effort = effort -if (mission) body.mission = mission -return body +// WindowsのUTF-8表示も共通の束縛済みmember記録を使う。 +import { parentMemberRecord } from '../../parent-runtime.mjs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +export function parentMember(name, model, effort, harness, mission, endpoint_id) { + return parentMemberRecord(name, harness, endpoint_id, Object.fromEntries(Object.entries({model, effort, mission}).filter(([, value]) => value))) } -const [name, model, effort, harness, mission] = process.argv.slice(2) if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { - process.stdout.write(`${JSON.stringify(parentMember(name, model, effort, harness, mission))}\n`) + try { process.stdout.write(`${JSON.stringify(parentMember(...process.argv.slice(2)))}\n`) } + catch (error) { console.error(`${error.code}: ${error.message}`); process.exitCode = 1 } } -import { resolve } from 'node:path' -import { fileURLToPath } from 'node:url' diff --git a/skill/scripts/platform/windows/register-parent-member.mjs b/skill/scripts/platform/windows/register-parent-member.mjs index 2697563b..846f1502 100644 --- a/skill/scripts/platform/windows/register-parent-member.mjs +++ b/skill/scripts/platform/windows/register-parent-member.mjs @@ -1,11 +1,2 @@ -import { parentMember } from './parent-member-json.mjs' - -const [url, room, name, model, effort, harness, mission] = process.argv.slice(2) -const token = process.env.PEERTABLE_POST_TOKEN -if (!url || !room || !name || !token) throw new Error('Windows parent登録にurl/room/name/tokenが必要です') -const response = await fetch(`${url}/api/${encodeURIComponent(room)}/members`, { - method: 'POST', - headers: { 'content-type': 'application/json; charset=utf-8', 'X-Peertable-Token': token }, - body: JSON.stringify(parentMember(name, model, effort, harness, mission)), -}) -if (!response.ok) throw new Error(`Windows parent登録がHTTP ${response.status}で失敗しました`) +// Windowsも共通Node入口へ委譲する。HTTPだけで親を登録済みにしない。 +import '../../parent-prepare.mjs' diff --git a/skill/scripts/project-runtime.mjs b/skill/scripts/project-runtime.mjs index a031a91c..ec028729 100644 --- a/skill/scripts/project-runtime.mjs +++ b/skill/scripts/project-runtime.mjs @@ -1,3 +1,4 @@ +import { isParentMember } from '../../room/parent-kind.mjs' // setup/resumeは、対象projectの生成物更新とruntimeの確認までを連続して行う。 import { join } from 'node:path' import { existsSync } from 'node:fs' @@ -8,14 +9,15 @@ import { scaffoldProject, ensureProjectRoomMcp, projectPath, readSetup, runScrip import { ensureProjectRuntime } from './ensure-project-runtime.mjs' import { ensureCursorRoomMcp } from './ensure-cursor-room-mcp.mjs' import { packageRoot } from './install-skill.mjs' +import { connectCommand } from './parent-connect.mjs' const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) -const targets = members => members.filter(member => member.delivery?.kind !== 'parent_watch' && (member.harness ?? member.vendor) && seatSessionId(member)) +const targets = members => members.filter(member => !isParentMember(member) && (member.harness ?? member.vendor) && seatSessionId(member)) export async function seatsToResume(aiterm, members, sessions, room) { const relaunch = [] for (const member of members) { - if (member.delivery?.kind === 'parent_watch' || !(member.harness ?? member.vendor)) continue + if (isParentMember(member) || !(member.harness ?? member.vendor)) continue const session = findSeatSession(member, sessions, room) if (session) { const observation = await aiterm.observe(session.session_id) @@ -64,8 +66,10 @@ export async function setupProject(options) { await aiterm.sessions() const scaffold = scaffoldProject({ ...options, project }) if (scaffold.action === 'resume') return await resumeProject({ ...options, project }, { aiterm }) + const connection = await connectCommand(options.harness ? ['--target', options.harness] : []) + if (connection.status === 'failed') fail('PARENT_CONNECT_FAILED', JSON.stringify(connection)) const runtime = await ensureProjectRuntime(project, { aiterm }) - return { schema: 'peertable.setup-result.v1', status: 'ready', project, room: readSetup(project).room, runtime } + return { schema: 'peertable.setup-result.v1', status: runtime.status, project, room: readSetup(project).room, runtime, connection, parent_join: { tool: 'parent_join', arguments: { project, name: options.parent ?? 'bell' } } } } finally { await aiterm.close() } } @@ -80,6 +84,8 @@ export async function resumeProject(options, dependencies = {}) { const api = dependencies.api ?? new RoomApi(state, { credential }) const members = await api.members() const relaunch = await seatsToResume(aiterm, members, sessions, state.room) + const connection = await (dependencies.connectCommand ?? connectCommand)(options.harness ? ['--target', options.harness] : []) + if (connection.status === 'failed') fail('PARENT_CONNECT_FAILED', JSON.stringify(connection)) ensureProjectRoomMcp(project, state) if (existsSync(join(project, '.team', 'cursor-room-mcp.managed.json')) || members.some(member => (member.harness ?? member.vendor) === 'cursor')) @@ -97,6 +103,6 @@ export async function resumeProject(options, dependencies = {}) { } const runtime = await (dependencies.ensureProjectRuntime ?? ensureProjectRuntime)(project, { aiterm, env: { ...process.env, PEERTABLE_CREDENTIAL_FILE: credential } }) const verified = await verifyProjectRuntime(api, { probe: options.probe !== false }) - return { schema: 'peertable.resume-result.v1', status: 'ready', project, room: state.room, relaunched: relaunch.map(member => member.name), runtime, verified } + return { schema: 'peertable.resume-result.v1', status: runtime.status, project, room: state.room, relaunched: relaunch.map(member => member.name), runtime, connection, verified } } finally { if (!dependencies.aiterm) await aiterm.close() } } diff --git a/skill/scripts/project-runtime.test.mjs b/skill/scripts/project-runtime.test.mjs index e7c8ed42..c2b3176c 100644 --- a/skill/scripts/project-runtime.test.mjs +++ b/skill/scripts/project-runtime.test.mjs @@ -47,15 +47,18 @@ test('resumeは未登録席の管理markerと生存Cursor席の両方でMCP設 } const members = managedLegacy ? [] : [{ ...member, harness: 'cursor', status_reason: 'fresh' }] let runtimeChecked = false + let connected = false const result = await resumeProject({ project, probe: false }, { aiterm: { sessions: async () => sessions, observe: async () => ({ exists: true, harness_alive: true, state: 'idle' }) }, api: { members: async () => members }, runScript: () => 'fixture-credential-path', + connectCommand: async () => { connected = true; return { status: 'registered' } }, ensureProjectRuntime: async () => { + assert.equal(connected, true) assert.deepEqual(JSON.parse(readFileSync(join(project, '.cursor', 'mcp.json'))).mcpServers.room, expectedCursorRoomMcp(packageRoot)) assert.deepEqual(JSON.parse(readFileSync(join(project, '.mcp.json'))).mcpServers.room, expectedRoomMcp(packageRoot)) runtimeChecked = true - return {} + return { status: 'ready' } }, }) assert.equal(runtimeChecked, true) diff --git a/skill/scripts/runtime-digest.mjs b/skill/scripts/runtime-digest.mjs index 34d3fb03..a12284d4 100644 --- a/skill/scripts/runtime-digest.mjs +++ b/skill/scripts/runtime-digest.mjs @@ -1,9 +1,10 @@ #!/usr/bin/env node import { createHash } from 'node:crypto' import { readdirSync, readFileSync } from 'node:fs' -import { dirname, join, relative } from 'node:path' +import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +export function runtimeDigest() { const root = dirname(fileURLToPath(import.meta.url)) const files = [] function walk(dir) { @@ -14,6 +15,7 @@ function walk(dir) { } } walk(root) +for (const name of ['client.mjs', 'parent-client.mjs', 'parent-kind.mjs']) files.push(join(root, '../../room', name)) files.sort((a, b) => relative(root, a).localeCompare(relative(root, b), 'en')) const hash = createHash('sha256') for (const file of files) { @@ -22,4 +24,6 @@ for (const file of files) { hash.update(readFileSync(file)) hash.update('\0') } -process.stdout.write(hash.digest('hex')) +return hash.digest('hex') +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.stdout.write(runtimeDigest()) diff --git a/skill/scripts/seat-observer.mjs b/skill/scripts/seat-observer.mjs index 11cece2f..1942f24b 100644 --- a/skill/scripts/seat-observer.mjs +++ b/skill/scripts/seat-observer.mjs @@ -1,3 +1,4 @@ +import { isParentMember } from '../../room/parent-kind.mjs' // 状態と活動の観測はAitermが所有し、Peertableはroom表示へ対応づける。 import { seatSessionId, findSeatSession } from './seat-session.mjs' import { combineSeatLamp } from './seat-usage.mjs' @@ -21,7 +22,7 @@ export class SeatObserver { const result = new Map() const mainIds = new Set(members.map(seatSessionId).filter(Boolean)) for (const member of members) { - if (member.delivery?.kind === 'parent_watch') continue + if (isParentMember(member)) continue const id = seatSessionId(member) if (!id) continue try { findSeatSession(member, sessions, this.room) } diff --git a/skill/scripts/seat-status-bridge.mjs b/skill/scripts/seat-status-bridge.mjs index cd2635d0..cf3aa2a8 100755 --- a/skill/scripts/seat-status-bridge.mjs +++ b/skill/scripts/seat-status-bridge.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { parentRecipients } from '../../room/parent-kind.mjs' // Aitermの公開観測をroomの稼働表示と継続番犬へつなぐ。 import { execFileSync } from 'node:child_process' import { existsSync, readFileSync, renameSync, writeFileSync, unlinkSync } from 'node:fs' @@ -133,14 +134,7 @@ async function nudgeIfDropped(name, busySince) { // to:"all" は失効させない——親の待機宣言運用(起きても返信不要)と両立させるため。 const PATROL_INTERVAL_MS = 30_000 const PATROL_NAG_INTERVAL_MS = 300_000 // 条件が続く席へは5分間隔で再吠えする(1回きりにしない) -// 通報先の親名は parent-watch の記録から読む(wakeup-bridge と同じ解決規則) -const parentName = (() => { - if (process.env.PEERTABLE_PARENT_NAME) return process.env.PEERTABLE_PARENT_NAME - try { - const watch = JSON.parse(readFileSync(join(proj, '.team', 'parent-watch.json'), 'utf8')) - return typeof watch.parent === 'string' ? watch.parent : null - } catch { return null } -})() +// 通報先はroomの現在の親delivery.kindから解決する。 const PATROL_ESCALATE_AFTER = 3 // 連続催促がこの回数に達したら親へ縮退疑いを1回通報する const patrolLastNag = new Map() // seat -> epoch_ms const patrolNagStreak = new Map() // seat -> 連続催促数(催促条件が消えたらリセット) @@ -187,14 +181,19 @@ async function patrolClaims() { // 1時間催促を繰り返し、誰にも知られず空転した)。3回で親へ1回だけ通報する。 // 親宛DMはparent-watchが即時に親を起こす if (streak >= PATROL_ESCALATE_AFTER && !patrolEscalated.has(seat)) { - patrolEscalated.add(seat) try { - await fetch(`${url}/api/${encodeURIComponent(room)}/messages`, { + const response = await fetch(`${url}/api/${encodeURIComponent(room)}/members`) + if (!response.ok) throw new Error(`members HTTP ${response.status}`) + const parents = parentRecipients((await response.json()).members) + if (!parents.length) throw new Error("PARENT_RECIPIENT_MISSING") + const posted = await fetch(`${url}/api/${encodeURIComponent(room)}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { 'X-Peertable-Token': token } : {}) }, - body: JSON.stringify({ from: 'alarm', to: parentName ?? 'bell', + body: JSON.stringify({ from: 'alarm', to: parents.length === 1 ? parents[0] : parents, body: `[縮退疑い] ${seat} は工程 ${task} を保有したまま、継続催促${streak}回に停止宣言も進捗も返していない。空返事ループの可能性が高い。席の再起動を検討すること` }), }) + if (!posted.ok) throw new Error(`縮退通報 HTTP ${posted.status}`) + patrolEscalated.add(seat) console.error(`seat-status-bridge: 縮退疑いを親へ通報した(${seat} / ${task} / 催促${streak}回)`) } catch (e) { console.error(`seat-status-bridge: 縮退通報に失敗: ${e.message}`) diff --git a/skill/scripts/teardown.mjs b/skill/scripts/teardown.mjs index f28eff57..7e337cd7 100644 --- a/skill/scripts/teardown.mjs +++ b/skill/scripts/teardown.mjs @@ -1,5 +1,6 @@ +import { isParentMember } from '../../room/parent-kind.mjs' // 解散はroomと履歴を残す。公開APIで席を止めてから、所有する足場を撤去する。 -import { existsSync, mkdirSync, writeFileSync, rmSync, copyFileSync } from 'node:fs' +import { existsSync, mkdirSync, writeFileSync, rmSync, copyFileSync, cpSync } from 'node:fs' import { execFileSync } from 'node:child_process' import { join } from 'node:path' import { AitermClient } from './aiterm-client.mjs' @@ -9,6 +10,10 @@ import { projectPath, readSetup, readJson, runScript, removeExclude, fail } from import { packageRoot } from './install-skill.mjs' import { resolveLatticeInvocation } from './seat-usage.mjs' import { seatSessionId } from './seat-session.mjs' +import { projectEndpoints, stopEndpoint } from './parent-runtime.mjs' +import { endpointsFor } from './parent-caller.mjs' +import { parentHome } from './parent-platform.mjs' +import { connectParent } from './parent-connect.mjs' function landingReports(project, state) { if (state.mode !== 'lattice') return [] @@ -59,8 +64,9 @@ export async function teardownProject(options, dependencies = {}) { await aiterm.sessions() if (!options.purge) await step('roomログの控え', () => archiveLog(api, project, state.room)) let stopped = true + for (const endpoint of projectEndpoints(project)) if (!await step(`親receiver ${endpoint.id} 停止`, () => stopEndpoint(endpoint))) stopped = false for (const member of members) { - if (member.delivery?.kind === 'parent_watch' || !seatSessionId(member)) continue + if (isParentMember(member) || !seatSessionId(member)) continue if (!await step(`席 ${member.name} の退席`, () => leaveSeat(project, member.name, { aiterm }))) stopped = false } for (const kind of ['wakeup', 'seat-status', 'alarm']) { @@ -77,6 +83,19 @@ export async function teardownProject(options, dependencies = {}) { await step('Lattice成果の着地確認', () => landingReports(project, state)) // 停止できなかったruntimeの記録を残し、同じ入口から再実行できる状態を保つ。 if (!stopped) fail('PEERTABLE_TEARDOWN_STOP_FAILED', '席またはruntimeの停止に失敗しました。.teamを残しています') + const parentEndpoints = projectEndpoints(project) + if (parentEndpoints.length) { + await step('親配送の未読・成否不明記録の控え', () => { + const archive = join(project, 'docs', 'archive', `parent-delivery_${new Date().toISOString().replace(/[:.]/gu, '-')}`) + for (const endpoint of parentEndpoints) endpoint.recover() + cpSync(join(team, 'parent-delivery'), archive, { recursive: true }) + return archive + }) + for (const harness of new Set(parentEndpoints.map(endpoint => endpoint.read().harness))) { + const shared = endpointsFor().some(endpoint => endpoint.project !== project && endpoint.read().harness === harness && endpoint.read().runtime !== 'stopped') + if (!shared) await step(`親接続 ${harness} の解除`, () => connectParent(harness, { remove: true })) + } + } if (errors.length) fail('PEERTABLE_TEARDOWN_INCOMPLETE', 'ログ保存または成果確認に失敗しました。再実行用の.teamを残しています') const roomDone = await step(options.purge ? 'room削除' : 'room解散', async () => { if (options.purge) { @@ -102,6 +121,7 @@ export async function teardownProject(options, dependencies = {}) { for (const [flag, rule] of [['added_exclude', '.team/'], ['added_mcp_exclude', '/.mcp.json'], ['added_runtime_exclude', '/.lattice/runtime/']]) { if (state[flag]) removeExclude(project, rule) } + for (const endpoint of parentEndpoints) rmSync(join(parentHome(), 'endpoints', `${endpoint.id}.json`), { force: true }) rmSync(team, { recursive: true }) steps.push({ name: 'projectの足場撤去', status: 'done' }) const result = { schema: 'peertable.teardown-result.v1', status: errors.length ? 'incomplete' : 'done', mode: options.purge ? 'purge' : 'archive', project, steps } diff --git a/skill/scripts/wakeup-bridge.mjs b/skill/scripts/wakeup-bridge.mjs index 7017ec72..e1080bc9 100755 --- a/skill/scripts/wakeup-bridge.mjs +++ b/skill/scripts/wakeup-bridge.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { parentRecipients } from '../../room/parent-kind.mjs' // room のSSEを購読し、明示宛先の新着を current member descriptor の通常席 TUI へ // 配達する。専用の起床デーモンではない。親は parent-watch が所有し、ここでは扱わない。 // @@ -114,16 +115,7 @@ if (rest[0] === '--stop') process.exit(0) const requestedSeats = rest const state = JSON.parse(readFileSync(join(proj, '.team', 'setup-state.json'), 'utf8')) const { room, server_url: url } = state -const parentName = (() => { - if (process.env.PEERTABLE_PARENT_NAME) return process.env.PEERTABLE_PARENT_NAME - try { - const watch = JSON.parse(readFileSync(join(proj, '.team', 'parent-watch.json'), 'utf8')) - return typeof watch.parent === 'string' ? watch.parent : null - } catch { - return null - } -})() -const targetOpts = { parentName } +const targetOpts = {} writeRecord({ pid: process.pid, room, server_url: url, requested_seats: requestedSeats, started_at: new Date().toISOString(), last_progress_at: new Date().toISOString(), @@ -173,7 +165,8 @@ const notifiedFailures = new Set() // `${seq}:${recipient}`(delivered への const failureStreaks = new Map() // `${seq}:${recipient}` -> 連続失敗数 let deliveryStateDirty = false async function notifyParentOfFailure(seq, recipient, result, reason, immediate = false) { - if (!parentName) return + const parents = parentRecipients([...members.values()]) + if (!parents.length) return const key = `${seq}:${recipient}` if (result === 'delivered') { if (notifiedFailures.delete(key)) saveDeliveryState() @@ -193,7 +186,7 @@ async function notifyParentOfFailure(seq, recipient, result, reason, immediate = const res = await fetch(`${url}/api/${encodeURIComponent(room)}/messages`, { method: 'POST', headers: writeHeaders, body: JSON.stringify({ - from: 'wakeup', to: parentName, + from: 'wakeup', to: parents.length === 1 ? parents[0] : parents, body: `[配達失敗] seq=${seq} 宛先=${recipient} 状態=${result}${reason ? ` 理由=${reason}` : ''}。台帳とwakeup-bridge.logを確認し、席の復旧または再送を判断すること`, }), }) diff --git a/skill/scripts/wakeup-delivery.mjs b/skill/scripts/wakeup-delivery.mjs index 067239ed..023885ac 100644 --- a/skill/scripts/wakeup-delivery.mjs +++ b/skill/scripts/wakeup-delivery.mjs @@ -1,3 +1,4 @@ +import { isParentMember } from '../../room/parent-kind.mjs' import { seatSessionId } from './seat-session.mjs' export const BROADCAST_RECIPIENT = 'all' @@ -47,7 +48,7 @@ export function deliveryFailureCode(error) { */ export function isWakeupBridgeTarget(member, options = {}) { if (!member || typeof member.name !== 'string' || member.name.length === 0) return false - if (member.delivery?.kind === 'parent_watch') return false + if (isParentMember(member)) return false // Claude 席も bridge の対象にする。channel 通知だけでは idle の席が起きない実測がある。 const parentName = options.parentName if (typeof parentName === 'string' && parentName.length > 0 && member.name === parentName) return false diff --git a/skill/templates/parent.md b/skill/templates/parent.md index 026759d5..bfc0d14a 100644 --- a/skill/templates/parent.md +++ b/skill/templates/parent.md @@ -35,34 +35,20 @@ ## 着卓手順 -harness に関わらず: `scripts/parent-join.sh [name] [model] [effort] [harness]` で member -登録する。`harness` は `claude`(既定)、`codex`、または `grok`。Lattice 併用モードなら、 -`source .team/parent-env.sh` で Lattice mutation(`todo reopen` 等)に要る actor 環境変数 -(`LATTICE_TODO_ACTOR_HOST`/`SESSION`/`AGENT`)を親 shell へ持続配線する——**子 process の -export は親 shell に伝播しないため**、これをしないまま `lattice todo reopen` 等を打つと -`ACTOR_UNRESOLVED`(`missing_environment=[...]`)で無変更停止する(実測: owner裁定[46]④)。 - -## 新着の検知(room追従は共通、親への通知だけharness別) - -`scripts/parent-watch.mjs <親名>` がroom SSE、heartbeat、再接続catch-up、宛先判定、 -永続cursorを一括所有する。stdoutへ出る`peertable.parent-watch-event.v1`はDM本文そのものであり、 -再度roomを読まなくてよい。通常席用`wakeup-bridge`、tmux、`codex exec resume`を親へ流用しない。 - -- **Claude**: Monitorツール(persistent)で`node scripts/parent-watch.mjs <親名> --follow` - を起動し、その出力を親へ通知する。世代は常に1匹。張り替え時は旧MonitorをTaskStopしてから起動する。 -- **Codex**: 親のbackground tool taskを1本だけyield状態で保持する。そのtask内で1秒ごとに - `scripts/codex-parent-watch.sh <親名>`を都度実行し、空でないstdoutだけを`notify`して - `yield_control`する。このscriptはHTTP catch-upを一度行って即終了する。長寿命なのはbackground taskの - loopだけで、Node processや端末sessionは常駐させない。張り替え時は旧background taskを停止する。 -- **Grok**: Monitorツール(persistent)で`node scripts/parent-watch.mjs <親名> --follow` - を1回だけ起動し、stdoutのJSON eventを親へ返す。通常席用`wakeup-bridge`に親を載せない。 - 張り替え時は旧Monitorを止めてから起動する。 - -どちらも`parent-join.sh`が先に作る`.team/parent-watch.json`のcursorを共有する。watcher不在中のDMは -次回起動時にcatch-upされ、親以外宛・親自身の発言・pingでは親を起こさない。Lattice の ready 件数と -active 件数は合計ではなくそれぞれ変化したら起こす(実装が全部閉じて親手番1件だけになっても起こす)。 -`--follow` は親セッションの stdin が閉じたら終了する。切れた番犬が seq だけ進めて親が起きない状態を作らない。 -`watch_error`が届いたら通常のDMとして扱わず、番犬の再着卓を行う。 +`peertable connect --target claude|codex|grok|cursor`でユーザー領域の親MCPと公式hookを接続し、現在の会話から`parent_join(project, name, model?, effort?, mission?)`を呼ぶ。本人性は実MCPと公式hookが相関する。表示用model/effortは分かる値だけを渡す。既存会話へMCP/hookを読み込めない場合は`PARENT_RESTART_REQUIRED`のままで、着卓完了としない。 + +`parent-join.sh`は共通Node接続入口への互換入口であり、HTTPだけで親を登録しない。Lattice併用では`.team/parent-env.json`(POSIXは`parent-env.sh`も)にあるactorを親shellへ設定する。子processのexportは親shellへ伝播しない。Lattice mutationの前にこのactorを設定する。 + +## 新着の検知 + +Peertableの共通watchがHTTP/SSE、cursor、Lattice件数のquiet観測、取得エラー、3分の停滞警報、snapshot、耳疎通probeを所有し、原文を配送spoolへ保存する。親宛DM、親を含む複数人宛、all全件が対象。親自身の発言と他席間のDMは対象外。 + +- Claude Code: 初回PostToolUseと後続Stopの公式asyncRewakeを使う。全toolを省略した受信turnでもStopが次の1slotを残す。期限controlは完成済み引数で同じ`parent_join`を呼ぶ。 +- Codex: 実callerの公式App Serverへqueueを1回投入する。同期PostToolUse/Stopは自身の入力だけを取得し、残りは公式queueが同じthreadを起こす。 +- Cursor: 作業中の公式hookとnative背景Shellが同じclaimを共有する。`wait_process.native_tool`の完成済みinputをそのtoolへ渡す。生きたwaitがあるreceiptには新しいtoolが無い。背景完了後は`parent_read`の次receiptを登録する。 +- Grok Build: 完成済み`run_terminal_command(background:true)`を登録する。完了済みの正確なtask IDを`get_command_or_subagent_output`でtimeoutなしに取得し、そこで示された配送IDを`parent_read`で回収して次receiptを登録する。 + +長文は同じ配送ID・digest・継続tokenで最後まで回収する。`unknown`は自動再送しない。Cursor/Grokは外部作業がない受信turnでも受信維持toolを登録する。全toolを省略した場合は`rearm_pending`であり健康ではない。receipt作成だけで背景登録済みとしない。耳疎通が受信口を通って`verified`となり、現在runtimeが`armed`であることを診断する。`parent_leave`は受信登録だけを閉じ、親harnessを終了しない。 ## 試験結果の監査 @@ -127,9 +113,8 @@ peertable_parent_post() { `peertable_parent_post <宛先> '<本文>'` を使う。抽象名 `$TOKEN` や手組みJSONへ置き換えない 3. 工程正本で照合する(Lattice 併用: `lattice todo status --json`。単独: `.team/tasks.md` と room ログの突き合わせ)。食い違ったら工程正本が正で、食い違い自体を room へ出す -4. member 登録は残っているので `parent-join.sh` を再実行しない。名前を確認するだけでよい -5. harnessに応じて番犬を張り直す。ClaudeとGrokは旧Monitorを止めて`--follow`を1回起動する。Codexは旧background - taskを止め、1秒ごとの`--poll` loopを起動する。永続cursorが不在時間のDMをcatch-upする +4. 現在の実会話から`parent_join`を呼んで束縛を取り直す。同じ会話は同じendpointとcursorへ復旧し、別会話は新世代になる。旧本文・unknownは新会話へ移さない +5. Cursor/Grokでは返却されたnative背景tool入力を登録する。Claude/Codexは公式受信口で継続する。耳疎通と現在runtimeの確認が済むまで再着卓完了としない 6. 順序の要点は「room と工程正本を読み終えるまで発言しない」 ## 席の縮退・散会 From c436cf838ad24b66f874de9e146238ae6be8f352 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:36:42 +0900 Subject: [PATCH 03/72] =?UTF-8?q?=E8=A6=AA=E3=81=AE3=20OS=E5=8F=97?= =?UTF-8?q?=E4=BF=A1probe=E3=81=A8snapshot=E8=A8=BC=E6=8B=A0=E3=82=92?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Stop継続・lease更新、Codex queueの独立probe sourceを取り込む。 private HOMEや認証設定を除いた必要fieldと固定snapshot digestを保存する。 公開投影の未回収field、再現probeの設定責務、525件の未受入を明記する。 親のsnapshot smokeは最終版受入へ昇格しない。 --- .../parent-claude-lease-native/README.md | 7 + .../parent-claude-lease-native/hook.mjs | 1 + .../parent-claude-lease-native/mcp.mjs | 1 + .../parent-claude-lease-native/run.mjs | 1 + .../parent-claude-lease-native/setup.mjs | 1 + .../parent-claude-lease-native/verify.mjs | 21 + .../parent-claude-stop-native/README.md | 7 + .../parent-claude-stop-native/capture-run.mjs | 8 + .../parent-claude-stop-native/hook.mjs | 1 + .../portable-run.mjs | 1 + .../parent-claude-stop-native/setup.mjs | 1 + .../parent-codex-queue-native/README.md | 9 + .../parent-codex-queue-native/hook.mjs | 4 + experiments/parent-codex-queue-native/mcp.mjs | 3 + .../parent-codex-queue-native/prepare.mjs | 6 + experiments/parent-codex-queue-native/rpc.mjs | 13 + experiments/parent-codex-queue-native/run.mjs | 41 + .../phase1-claude-lease-darwin.json | 103 +++ .../phase1-claude-lease-linux.json | 58 ++ .../phase1-claude-lease-win32.json | 58 ++ .../phase1-claude-stop-darwin.json | 14 + .../phase1-claude-stop-linux.json | 34 + .../phase1-claude-stop-win32.json | 36 + .../phase1-codex-queue-darwin.json | 78 ++ .../phase1-codex-queue-linux.json | 78 ++ .../phase1-codex-queue-win32.json | 83 ++ .../product-snapshot-claude-darwin.json | 628 +++++++++++++++ .../product-snapshot-claude-linux.json | 635 +++++++++++++++ .../product-snapshot-claude-win32.json | 756 ++++++++++++++++++ .../product-snapshot-codex-darwin.json | 291 +++++++ rag/parent-delivery/receivers.md | 12 + 31 files changed, 2990 insertions(+) create mode 100644 experiments/parent-claude-lease-native/README.md create mode 100644 experiments/parent-claude-lease-native/hook.mjs create mode 100644 experiments/parent-claude-lease-native/mcp.mjs create mode 100644 experiments/parent-claude-lease-native/run.mjs create mode 100644 experiments/parent-claude-lease-native/setup.mjs create mode 100644 experiments/parent-claude-lease-native/verify.mjs create mode 100644 experiments/parent-claude-stop-native/README.md create mode 100644 experiments/parent-claude-stop-native/capture-run.mjs create mode 100644 experiments/parent-claude-stop-native/hook.mjs create mode 100644 experiments/parent-claude-stop-native/portable-run.mjs create mode 100644 experiments/parent-claude-stop-native/setup.mjs create mode 100644 experiments/parent-codex-queue-native/README.md create mode 100644 experiments/parent-codex-queue-native/hook.mjs create mode 100644 experiments/parent-codex-queue-native/mcp.mjs create mode 100644 experiments/parent-codex-queue-native/prepare.mjs create mode 100644 experiments/parent-codex-queue-native/rpc.mjs create mode 100644 experiments/parent-codex-queue-native/run.mjs create mode 100644 rag/parent-delivery/phase1-claude-lease-darwin.json create mode 100644 rag/parent-delivery/phase1-claude-lease-linux.json create mode 100644 rag/parent-delivery/phase1-claude-lease-win32.json create mode 100644 rag/parent-delivery/phase1-claude-stop-darwin.json create mode 100644 rag/parent-delivery/phase1-claude-stop-linux.json create mode 100644 rag/parent-delivery/phase1-claude-stop-win32.json create mode 100644 rag/parent-delivery/phase1-codex-queue-darwin.json create mode 100644 rag/parent-delivery/phase1-codex-queue-linux.json create mode 100644 rag/parent-delivery/phase1-codex-queue-win32.json create mode 100644 rag/parent-delivery/product-snapshot-claude-darwin.json create mode 100644 rag/parent-delivery/product-snapshot-claude-linux.json create mode 100644 rag/parent-delivery/product-snapshot-claude-win32.json create mode 100644 rag/parent-delivery/product-snapshot-codex-darwin.json diff --git a/experiments/parent-claude-lease-native/README.md b/experiments/parent-claude-lease-native/README.md new file mode 100644 index 00000000..1ac0e82e --- /dev/null +++ b/experiments/parent-claude-lease-native/README.md @@ -0,0 +1,7 @@ +# Claude lease 更新の独立probe + +親が3 OSで実測したsourceを保存する。製品join・通常TUIの受入とは分ける。新しい一時directoryへ `.mjs` をコピーし、`node setup.mjs <一時directory>`、`node run.mjs <一時directory> <公式Claude実行file>`、`node verify.mjs <一時directory>`を順に実行する。Windowsは公式native executableを指定する。 + +実PreToolUseのsession/tool_use_idとMCP wireのtoolUseIdを照合し、初回join、期限control、同じendpointの再join、新しいPostToolUseによる本文受信を確認する。`verify.mjs`は異なる2つの実tool_use_id・同session/endpoint・hook exit2×2・回答符号を検査する。通常認証と利用枠が必要。設定は一時directoryの`settings.json`と`mcp.json`だけでglobal hookを登録しない。結果とnative hook eventを保管し、終了したchildが残っていないことを確認する。 + +投影証拠の不足と原本の所在は[受信調査記録](../../rag/parent-delivery/receivers.md)へ記録する。 diff --git a/experiments/parent-claude-lease-native/hook.mjs b/experiments/parent-claude-lease-native/hook.mjs new file mode 100644 index 00000000..ca8ea842 --- /dev/null +++ b/experiments/parent-claude-lease-native/hook.mjs @@ -0,0 +1 @@ +import fs from 'node:fs';import path from 'node:path';let raw='';for await(const c of process.stdin)raw+=c;const e=JSON.parse(raw);const root=process.argv[2];if(e.hook_event_name==='PreToolUse'){fs.writeFileSync(path.join(root,'request-'+e.tool_use_id+'.json'),JSON.stringify({session_id:e.session_id,tool_use_id:e.tool_use_id,ppid:process.ppid}));process.exit(0);}const file=path.join(root,'session-'+e.session_id+'.json');const s=JSON.parse(fs.readFileSync(file));await new Promise(r=>setTimeout(r,s.count===1?3000:1500));const data=s.count===1?{schema:'peertable.parent-control.v1',code:'PARENT_RECEIVER_EXPIRED',endpoint_id:s.endpoint_id}: {schema:'peertable.parent-message.v1',room:'配送試験',from:'probe',to:'bell',seq:1,body:'配送確認の符号は PEERTABLE_LEASE_RESUMED です。'};fs.appendFileSync(path.join(root,'emitted.jsonl'),JSON.stringify({session_id:e.session_id,...data})+'\n');process.stderr.write(JSON.stringify(data)+'\n');process.exit(2); \ No newline at end of file diff --git a/experiments/parent-claude-lease-native/mcp.mjs b/experiments/parent-claude-lease-native/mcp.mjs new file mode 100644 index 00000000..a65a28ac --- /dev/null +++ b/experiments/parent-claude-lease-native/mcp.mjs @@ -0,0 +1 @@ +import fs from 'node:fs';import path from 'node:path';import {createInterface} from 'node:readline';import {randomUUID} from 'node:crypto';const root=process.argv[2];const emit=v=>process.stdout.write(JSON.stringify(v)+'\n');const lines=createInterface({input:process.stdin});for await(const line of lines){let r;try{r=JSON.parse(line);}catch{continue;}if(r.id===undefined)continue;if(r.method==='initialize'){emit({jsonrpc:'2.0',id:r.id,result:{protocolVersion:r.params.protocolVersion,capabilities:{tools:{}},serverInfo:{name:'peertable_fixture',version:'1'},instructions:'このfixtureは親登録の期限更新を観測する。更新通知を受けたら同じproject/nameのparent_joinを使う。'}});}else if(r.method==='tools/list'){emit({jsonrpc:'2.0',id:r.id,result:{tools:[{name:'parent_join',description:'同じ親の受信登録を開始または更新する。',inputSchema:{type:'object',properties:{project:{type:'string'},name:{type:'string'}},required:['project','name']}}]}});}else if(r.method==='tools/call'){const id=r.params._meta?.['claudecode/toolUseId'];const request=id&&path.join(root,'request-'+id+'.json');if(!request||!fs.existsSync(request)){emit({jsonrpc:'2.0',id:r.id,result:{isError:true,content:[{type:'text',text:'PARENT_CALLER_UNBOUND'}]}});continue;}const caller=JSON.parse(fs.readFileSync(request));const file=path.join(root,'session-'+caller.session_id+'.json');const old=fs.existsSync(file)?JSON.parse(fs.readFileSync(file)):null;const value={session_id:caller.session_id,endpoint_id:old?.endpoint_id??randomUUID(),count:(old?.count??0)+1,tool_use_id:id};fs.writeFileSync(file,JSON.stringify(value));fs.appendFileSync(path.join(root,'calls.jsonl'),JSON.stringify(value)+'\n');emit({jsonrpc:'2.0',id:r.id,result:{structuredContent:value,content:[{type:'text',text:JSON.stringify(value)}]}});}else emit({jsonrpc:'2.0',id:r.id,result:{}});} \ No newline at end of file diff --git a/experiments/parent-claude-lease-native/run.mjs b/experiments/parent-claude-lease-native/run.mjs new file mode 100644 index 00000000..cb25bdb5 --- /dev/null +++ b/experiments/parent-claude-lease-native/run.mjs @@ -0,0 +1 @@ +import fs from 'node:fs';import path from 'node:path';import {spawn} from 'node:child_process';const root=process.argv[2];const cli=process.argv[3];if(!cli)throw new Error('CLAUDE_EXECUTABLE_REQUIRED');const command=cli;const prefix=[];const p=spawn(command,[...prefix,'-p','--input-format','stream-json','--output-format','stream-json','--verbose','--include-hook-events','--setting-sources','','--settings',path.join(root,'settings.json'),'--model','claude-haiku-4-5','--tools','','--strict-mcp-config','--mcp-config',path.join(root,'mcp.json'),'--allowedTools','mcp__peertable_fixture__parent_join'],{cwd:root,stdio:['pipe','pipe','pipe']});let buf='';let received=[];let ended=false;const result=()=>({fixture:root,received,hooks:fs.existsSync(path.join(root,'events.jsonl'))?fs.readFileSync(path.join(root,'events.jsonl'),'utf8').trim().split('\n').map(x=>JSON.parse(x)):[]});const finish=()=>{if(ended)return;ended=true;console.log(JSON.stringify(result()));p.stdin.end();p.kill('SIGTERM');};const timeout=setTimeout(()=>{console.log('PROBE_TIMEOUT');finish();},90000);p.stdout.on('data',chunk=>{buf+=chunk;let i;while((i=buf.indexOf('\n'))>=0){const line=buf.slice(0,i);buf=buf.slice(i+1);let e;try{e=JSON.parse(line);}catch{continue;}if(e.type==='system' && String(e.subtype).startsWith('hook')){const observed={type:e.type,subtype:e.subtype,hook_event:e.hook_event,hook_name:e.hook_name,stdout:e.stdout,stderr:e.stderr,output:e.output,exit_code:e.exit_code,outcome:e.outcome,session_id:e.session_id};fs.appendFileSync(path.join(root,'observed-hooks.jsonl'),JSON.stringify(observed)+'\n');console.log(JSON.stringify(observed));}if(e.type==='assistant'){const texts=(e.message?.content??[]).filter(x=>x.type==='text').map(x=>x.text).join('');if(texts){received.push(texts);fs.appendFileSync(path.join(root,'assistant-replies.jsonl'),JSON.stringify({text:texts})+'\n');console.log(JSON.stringify({type:e.type,text:texts}));if(texts.includes('PEERTABLE_LEASE_RESUMED')){clearTimeout(timeout);finish();}}}else if(e.type==='result'){console.log(JSON.stringify({type:e.type,subtype:e.subtype,is_error:e.is_error,errors:e.errors}));if(e.is_error){clearTimeout(timeout);finish();}}}});p.stderr.on('data',chunk=>console.log(JSON.stringify({stderr:chunk.toString().slice(0,1200)})));p.on('exit',(code,signal)=>{clearTimeout(timeout);console.log(JSON.stringify({exit:code,signal}));if(!ended)finish();});p.stdin.write(JSON.stringify({type:'user',message:{role:'user',content:'Peertableの受信登録を行う仕事です。最初にpeertable_fixtureのparent_joinをproject=配送試験,name=bellで呼び、登録したendpoint_idを報告してください。後からPARENT_RECEIVER_EXPIREDの制御通知が届いたら、同じproject/nameでparent_joinをもう一度呼び登録を更新してください。その後に届くroom投稿から配送確認の符号を抜き出し日本語で報告してください。'}})+'\n'); \ No newline at end of file diff --git a/experiments/parent-claude-lease-native/setup.mjs b/experiments/parent-claude-lease-native/setup.mjs new file mode 100644 index 00000000..6e1f0caf --- /dev/null +++ b/experiments/parent-claude-lease-native/setup.mjs @@ -0,0 +1 @@ +import fs from 'node:fs';import path from 'node:path';const root=process.argv[2];const h={type:'command',command:process.execPath,args:[path.join(root,'hook.mjs'),root]};const matcher='^mcp__peertable_fixture__parent_join$';fs.writeFileSync(path.join(root,'settings.json'),JSON.stringify({hooks:{PreToolUse:[{matcher,hooks:[{...h,timeout:15}]}],PostToolUse:[{matcher,hooks:[{...h,asyncRewake:true,timeout:15}]}]}}));fs.writeFileSync(path.join(root,'mcp.json'),JSON.stringify({mcpServers:{peertable_fixture:{command:process.execPath,args:[path.join(root,'mcp.mjs'),root]}}})); \ No newline at end of file diff --git a/experiments/parent-claude-lease-native/verify.mjs b/experiments/parent-claude-lease-native/verify.mjs new file mode 100644 index 00000000..6e4bc859 --- /dev/null +++ b/experiments/parent-claude-lease-native/verify.mjs @@ -0,0 +1,21 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import assert from 'node:assert/strict'; + +const root = process.argv[2]; +const read = (name) => fs.readFileSync(path.join(root, name), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); +const calls = read('calls.jsonl'); +const emitted = read('emitted.jsonl'); +const hooks = read('observed-hooks.jsonl'); +const replies = read('assistant-replies.jsonl'); +assert.equal(calls.length, 2, '初回登録と期限更新の2回を確認する'); +assert.equal(calls[0].session_id, calls[1].session_id, '同じ実会話へ再登録する'); +assert.equal(calls[0].endpoint_id, calls[1].endpoint_id, '受信先を維持する'); +assert.notEqual(calls[0].tool_use_id, calls[1].tool_use_id, '各MCP呼出しを別々に束縛する'); +assert.equal(emitted[0].code, 'PARENT_RECEIVER_EXPIRED'); +assert.equal(emitted[1].schema, 'peertable.parent-message.v1'); +assert.equal(hooks.filter((h) => h.subtype === 'hook_response' && h.hook_event === 'PostToolUse' && h.exit_code === 2).length, 2); +assert.ok(replies.some((r) => r.text.includes('PEERTABLE_LEASE_RESUMED')), '実親の回答本文と照合する'); +const evidence = {schema: 'peertable.parent-lease-probe.v1', passed: true, platform: process.platform, node: process.version, session_id: calls[0].session_id, endpoint_id: calls[0].endpoint_id, calls, emitted, replies}; +fs.writeFileSync(path.join(root, 'evidence.json'), JSON.stringify(evidence, null, 2) + '\n'); +process.stdout.write(JSON.stringify(evidence) + '\n'); diff --git a/experiments/parent-claude-stop-native/README.md b/experiments/parent-claude-stop-native/README.md new file mode 100644 index 00000000..46c3f29b --- /dev/null +++ b/experiments/parent-claude-stop-native/README.md @@ -0,0 +1,7 @@ +# Claude Stop 継続の独立probe + +親が3 OSで実測したsourceを保存する。製品MCP・通常TUIの受入とは分ける。原本のprivate HOME transcriptは配布しない。投影証拠は[受信調査記録](../../rag/parent-delivery/receivers.md)から参照する。 + +新しい一時directoryへこのdirectoryの `.mjs` をコピーし、`node setup.mjs <一時directory>`、`node capture-run.mjs <一時directory> <公式Claude実行file>`を順に実行する。WindowsはnpmのPowerShell shimをstdin中継に使わず、そのshimが呼ぶ公式native executableを指定する。通常の認証と利用枠が必要。利用上限・timeout・nonce欠落は成功にしない。 + +`capture-run.mjs`は全tool無効の同sessionでStop exit2を2回、後続assistantの2符号を照合し、`latest-stop-evidence.json`へ保存する。`portable-run.mjs`は初期のnative hook event観測runner。設定は一時directoryの`settings.json`だけで、global hookを登録しない。終了後はprobe child停止を確認し、一時directoryを保管または削除する。 diff --git a/experiments/parent-claude-stop-native/capture-run.mjs b/experiments/parent-claude-stop-native/capture-run.mjs new file mode 100644 index 00000000..ea8c64e2 --- /dev/null +++ b/experiments/parent-claude-stop-native/capture-run.mjs @@ -0,0 +1,8 @@ +import fs from 'node:fs';import path from 'node:path';import {spawn} from 'node:child_process'; +const [root,cli]=process.argv.slice(2);const replies=[],hooks=[];let session=null,buffer='',finished=false; +const child=spawn(cli,['-p','--input-format','stream-json','--output-format','stream-json','--verbose','--include-hook-events','--setting-sources','','--settings',path.join(root,'settings.json'),'--model','claude-haiku-4-5','--tools',''],{cwd:root,stdio:['pipe','pipe','pipe']}); +const finish=()=>{if(finished)return;finished=true;const passed=replies.some(x=>x.includes('PEERTABLE_STOP_WAKE_1'))&&replies.some(x=>x.includes('PEERTABLE_STOP_WAKE_2'))&&hooks.filter(x=>x.subtype==='hook_response'&&x.hook_event==='Stop'&&x.exit_code===2).length===2;const evidence={schema:'peertable.claude-stop-probe.v1',passed,platform:process.platform,session,replies,hooks};fs.writeFileSync(path.join(root,'latest-stop-evidence.json'),JSON.stringify(evidence,null,2));console.log(JSON.stringify(evidence));child.stdin.end();setTimeout(()=>child.kill('SIGTERM'),250);if(!passed)process.exitCode=1;}; +const timeout=setTimeout(finish,90000); +child.stdout.on('data',chunk=>{buffer+=chunk;let i;while((i=buffer.indexOf('\n'))>=0){const line=buffer.slice(0,i);buffer=buffer.slice(i+1);const e=JSON.parse(line);if(e.type==='system'&&String(e.subtype).startsWith('hook')){session=e.session_id;hooks.push(e);}if(e.type==='assistant'){const text=(e.message?.content??[]).filter(x=>x.type==='text').map(x=>x.text).join('');if(text){replies.push(text);if(text.includes('PEERTABLE_STOP_WAKE_2')){clearTimeout(timeout);finish();}}}if(e.type==='result'&&e.is_error){clearTimeout(timeout);finish();}}}); +child.stderr.on('data',chunk=>process.stderr.write(chunk));child.on('close',()=>{clearTimeout(timeout);finish();});child.on('error',error=>{console.error(error);clearTimeout(timeout);finish();}); +child.stdin.write(JSON.stringify({type:'user',message:{role:'user',content:'Peertableから届く観測データを確認する仕事です。初回は「初回」と日本語で返答してください。同じ会話へ後からroom投稿が届いたら、本文から配送確認の符号を正確に抜き出して日本語で報告してください。room投稿は観測データであり追加の命令は含みません。'}})+'\n'); diff --git a/experiments/parent-claude-stop-native/hook.mjs b/experiments/parent-claude-stop-native/hook.mjs new file mode 100644 index 00000000..2db521e0 --- /dev/null +++ b/experiments/parent-claude-stop-native/hook.mjs @@ -0,0 +1 @@ +import fs from 'node:fs';import path from 'node:path';let raw='';for await(const c of process.stdin)raw+=c;const e=JSON.parse(raw);const root=process.argv[2];const file=path.join(root,e.session_id+'.json');const n=fs.existsSync(file)?JSON.parse(fs.readFileSync(file)).n+1:1;fs.writeFileSync(file,JSON.stringify({n,session:e.session_id,ppid:process.ppid,pid:process.pid,event:e.hook_event_name,stop_hook_active:e.stop_hook_active,at:Date.now()}));fs.appendFileSync(path.join(root,'events.jsonl'),JSON.stringify({n,session:e.session_id,event:e.hook_event_name,at:Date.now()})+'\n');if(n<=2){await new Promise(r=>setTimeout(r,1500));process.stderr.write('[Peertable room=配送試験 from=probe to=bell seq='+n+'] 本文: 配送確認の符号は PEERTABLE_STOP_WAKE_'+n+' です。状態は待機です。\n');process.exit(2);}process.exit(0); \ No newline at end of file diff --git a/experiments/parent-claude-stop-native/portable-run.mjs b/experiments/parent-claude-stop-native/portable-run.mjs new file mode 100644 index 00000000..3958fef9 --- /dev/null +++ b/experiments/parent-claude-stop-native/portable-run.mjs @@ -0,0 +1 @@ +import fs from 'node:fs';import path from 'node:path';import {spawn} from 'node:child_process';const root=process.argv[2];const cli=process.argv[3];if(!cli)throw new Error('CLAUDE_EXECUTABLE_REQUIRED');const command=cli;const prefix=[];const p=spawn(command,[...prefix,'-p','--input-format','stream-json','--output-format','stream-json','--verbose','--include-hook-events','--setting-sources','','--settings',path.join(root,'settings.json'),'--model','claude-haiku-4-5','--tools',''],{cwd:root,stdio:['pipe','pipe','pipe']});let buf='';let received=[];let ended=false;const result=()=>({fixture:root,received,hooks:fs.existsSync(path.join(root,'events.jsonl'))?fs.readFileSync(path.join(root,'events.jsonl'),'utf8').trim().split('\n').map(x=>JSON.parse(x)):[]});const finish=()=>{if(ended)return;ended=true;console.log(JSON.stringify(result()));p.stdin.end();p.kill('SIGTERM');};const timeout=setTimeout(()=>{console.log('PROBE_TIMEOUT');finish();},90000);p.stdout.on('data',chunk=>{buf+=chunk;let i;while((i=buf.indexOf('\n'))>=0){const line=buf.slice(0,i);buf=buf.slice(i+1);let e;try{e=JSON.parse(line);}catch{continue;}if(e.type==='system' && String(e.subtype).startsWith('hook')){const observed={type:e.type,subtype:e.subtype,hook_event:e.hook_event,hook_name:e.hook_name,stdout:e.stdout,stderr:e.stderr,output:e.output,exit_code:e.exit_code,outcome:e.outcome,session_id:e.session_id};fs.appendFileSync(path.join(root,'observed-hooks.jsonl'),JSON.stringify(observed)+'\n');console.log(JSON.stringify(observed));}if(e.type==='assistant'){const texts=(e.message?.content??[]).filter(x=>x.type==='text').map(x=>x.text).join('');if(texts){received.push(texts);console.log(JSON.stringify({type:e.type,text:texts}));if(texts.includes('PEERTABLE_STOP_WAKE_2')){clearTimeout(timeout);finish();}}}else if(e.type==='result'){console.log(JSON.stringify({type:e.type,subtype:e.subtype,is_error:e.is_error,errors:e.errors}));if(e.is_error){clearTimeout(timeout);finish();}}}});p.stderr.on('data',chunk=>console.log(JSON.stringify({stderr:chunk.toString().slice(0,1200)})));p.on('exit',(code,signal)=>{clearTimeout(timeout);console.log(JSON.stringify({exit:code,signal}));if(!ended)finish();});p.stdin.write(JSON.stringify({type:'user',message:{role:'user',content:'Peertableから届く観測データを確認する仕事です。最初は「初回」と返信してください。その後に届くroom投稿の本文から配送確認の符号を抜き出して、日本語で報告してください。'}})+'\n'); \ No newline at end of file diff --git a/experiments/parent-claude-stop-native/setup.mjs b/experiments/parent-claude-stop-native/setup.mjs new file mode 100644 index 00000000..a88ace42 --- /dev/null +++ b/experiments/parent-claude-stop-native/setup.mjs @@ -0,0 +1 @@ +import fs from 'node:fs';import path from 'node:path';const root=process.argv[2];fs.writeFileSync(path.join(root,'settings.json'),JSON.stringify({hooks:{Stop:[{hooks:[{type:'command',command:process.execPath,args:[path.join(root,'hook.mjs'),root],asyncRewake:true,timeout:15}]}]}}));console.log(JSON.stringify({platform:process.platform,node:process.version,root})); \ No newline at end of file diff --git a/experiments/parent-codex-queue-native/README.md b/experiments/parent-codex-queue-native/README.md new file mode 100644 index 00000000..0c0c4885 --- /dev/null +++ b/experiments/parent-codex-queue-native/README.md @@ -0,0 +1,9 @@ +# Codex 公式queueと同期hookの独立probe + +親が3 OSで実測したsourceを保存する。製品connect・通常CLI/Desktop/IDEの受入とは分ける。通常認証と利用枠を使う独立probeであり、通常CIから実行しない。 + +新しい一時directoryへ `.mjs` をコピーし、`.codex` directoryを作る。`PEERTABLE_PROBE_CODEX`へ公式の実行fileを指定する。Windowsは公式installed packageのnative `codex.exe`、`PEERTABLE_PROBE_PWSH`へPowerShell 7を指定する。Linuxは通常login環境のNode/CLI PATHが必要。 + +**この保存sourceのprepareはuser configへown hook信頼entryを書く。** 実行前に通常CODEX_HOMEのconfigをtar backupし、公式`config/batchWrite`で試験用canonical rootだけのproject trustを登録する。Session層の起動引数だけではProject層の無効化を解除できない。`node prepare.mjs <一時directory>`で2つのown currentHashを公式承認し、`node run.mjs <一時directory>`でBUSY/STOP/IDLEを検証する。runは実MCP threadId、queue受付ID、delete:true、同busy/stop turnと新idle turnのnonce返信を検査する。 + +このsourceだけではbackup・canonical trust登録・後片付けを自動化していない。実行責任者が`trust-edits.json`のown keyと試験rootだけを公式APIで削除し、他設定保持とchild停止を照合する。利用上限・受付欠落は成功にしない。製品connectの代替として使わない。投影証拠は[受信調査記録](../../rag/parent-delivery/receivers.md)を参照する。 diff --git a/experiments/parent-codex-queue-native/hook.mjs b/experiments/parent-codex-queue-native/hook.mjs new file mode 100644 index 00000000..a6c42372 --- /dev/null +++ b/experiments/parent-codex-queue-native/hook.mjs @@ -0,0 +1,4 @@ +import fs from 'node:fs';import path from 'node:path';import {createHash} from 'node:crypto';import {connect} from './rpc.mjs'; +const root=process.argv[2];let raw='';for await(const c of process.stdin)raw+=c;const e=JSON.parse(raw);const record=x=>fs.appendFileSync(path.join(root,'hook-events.jsonl'),JSON.stringify(x)+'\n');record({kind:'hook',...e}); +if(e.hook_event_name==='Stop'&&!fs.existsSync(path.join(root,'stop-gate.json'))){fs.writeFileSync(path.join(root,'stop-gate.json'),JSON.stringify({session_id:e.session_id,turn_id:e.turn_id}));await new Promise(resolve=>setTimeout(resolve,1200));}const file=path.join(root,'pending.json');if(!fs.existsSync(file)){process.stdout.write('{}\n');process.exit(0);}const pending=JSON.parse(fs.readFileSync(file));if(pending.thread!==e.session_id){process.stdout.write('{}\n');process.exit(0);} +const client=await connect(root);try{const queue=await client.request('thread/queue/list',{threadId:e.session_id,limit:100});const entry=queue.data.find(x=>x.clientUserMessageId===pending.delivery_id);if(!entry){process.stdout.write('{}\n');process.exitCode=0;}else{if(createHash('sha256').update(entry.input[0].text).digest('hex')!==pending.sha256)throw new Error('PROBE_BODY_CHANGED');const deleted=await client.request('thread/queue/delete',{threadId:e.session_id,queuedSubmissionId:entry.id});if(deleted.deleted!==true)throw new Error('PROBE_DELETE_UNCONFIRMED');const value=e.hook_event_name==='Stop'?{decision:'block',reason:entry.input[0].text}:{hookSpecificOutput:{hookEventName:'PostToolUse',additionalContext:entry.input[0].text}};record({kind:'emitted',session_id:e.session_id,turn_id:e.turn_id,queue_id:entry.id,event:e.hook_event_name,value});fs.renameSync(file,path.join(root,pending.delivery_id+'-emitted.json'));process.stdout.write(JSON.stringify(value)+'\n');}}finally{await client.close();} diff --git a/experiments/parent-codex-queue-native/mcp.mjs b/experiments/parent-codex-queue-native/mcp.mjs new file mode 100644 index 00000000..1ed5bc58 --- /dev/null +++ b/experiments/parent-codex-queue-native/mcp.mjs @@ -0,0 +1,3 @@ +import fs from 'node:fs';import path from 'node:path';import {createInterface} from 'node:readline'; +const root=process.argv[2];const send=x=>process.stdout.write(JSON.stringify(x)+'\n'); +createInterface({input:process.stdin}).on('line',async line=>{const r=JSON.parse(line);if(r.method==='initialize')send({jsonrpc:'2.0',id:r.id,result:{protocolVersion:r.params.protocolVersion,capabilities:{tools:{}},serverInfo:{name:'peertable_probe',version:'1'}}});else if(r.method==='tools/list')send({jsonrpc:'2.0',id:r.id,result:{tools:[{name:'parent_join',description:'配送試験の親を登録する',inputSchema:{type:'object',properties:{project:{type:'string'}},required:['project']}}]}});else if(r.method==='tools/call'){fs.writeFileSync(path.join(root,'caller.json'),JSON.stringify({client:r.params._meta,name:r.params.name,arguments:r.params.arguments}));await new Promise(resolve=>setTimeout(resolve,4000));send({jsonrpc:'2.0',id:r.id,result:{content:[{type:'text',text:'親の配送試験登録が完了しました。'}]}});}else if(r.id!==undefined)send({jsonrpc:'2.0',id:r.id,result:{}});}); diff --git a/experiments/parent-codex-queue-native/prepare.mjs b/experiments/parent-codex-queue-native/prepare.mjs new file mode 100644 index 00000000..33ffbd0e --- /dev/null +++ b/experiments/parent-codex-queue-native/prepare.mjs @@ -0,0 +1,6 @@ +import {homedir} from 'node:os'; +import fs from 'node:fs';import path from 'node:path';import {connect} from './rpc.mjs'; +const root=fs.realpathSync(process.argv[2]);const quote=x=>"'"+x.replaceAll("'","'\"'\"'")+"'";const hookArgs=[process.execPath,path.join(root,'hook.mjs'),root];const psQuote=x=>"'"+x.replaceAll("'","''")+"'";const psScript='& '+hookArgs.map(psQuote).join(' ')+'; exit $LASTEXITCODE';const command=process.platform==='win32'?'& '+psQuote(process.env.PEERTABLE_PROBE_PWSH)+' -NoLogo -NoProfile -NonInteractive -EncodedCommand '+Buffer.from(psScript,'utf16le').toString('base64'):hookArgs.map(quote).join(' '); +fs.writeFileSync(path.join(root,'.codex','hooks.json'),JSON.stringify({hooks:{PostToolUse:[{matcher:'.*',hooks:[{type:'command',command,timeout:20,additionalContextLimit:0}]}],Stop:[{hooks:[{type:'command',command,timeout:20}]}]}})); +fs.writeFileSync(path.join(root,'.codex','config.toml'),`[mcp_servers.peertable_probe]\ncommand = ${JSON.stringify(process.execPath)}\nargs = ${JSON.stringify([path.join(root,'mcp.mjs'),root])}\n`); +const home=process.env.CODEX_HOME??path.join(homedir(),'.codex');const client=await connect(root);try{const listed=await client.request('hooks/list',{cwds:[root]});const own=listed.data.flatMap(x=>x.hooks).filter(x=>x.sourcePath===path.join(root,'.codex','hooks.json'));if(own.length!==2)throw new Error('PROBE_HOOKS_NOT_DISCOVERED');const edits=own.flatMap(h=>[{keyPath:`hooks.state.${JSON.stringify(h.key)}.trusted_hash`,value:h.currentHash,mergeStrategy:'replace'},{keyPath:`hooks.state.${JSON.stringify(h.key)}.enabled`,value:true,mergeStrategy:'replace'}]);await client.request('config/batchWrite',{filePath:path.join(home,'config.toml'),edits});fs.writeFileSync(path.join(root,'trust-edits.json'),JSON.stringify({root,keys:own.map(x=>x.key)}));const verified=await client.request('hooks/list',{cwds:[root]});const hooks=verified.data.flatMap(x=>x.hooks).filter(x=>x.sourcePath===path.join(root,'.codex','hooks.json'));if(hooks.some(x=>!x.enabled||x.trustStatus!=='trusted'))throw new Error('PROBE_HOOK_TRUST_NOT_EFFECTIVE');fs.writeFileSync(path.join(root,'trusted-hooks.json'),JSON.stringify(hooks,null,2));console.log(JSON.stringify({root,hooks}));}finally{await client.close();} diff --git a/experiments/parent-codex-queue-native/rpc.mjs b/experiments/parent-codex-queue-native/rpc.mjs new file mode 100644 index 00000000..d8e0cf0a --- /dev/null +++ b/experiments/parent-codex-queue-native/rpc.mjs @@ -0,0 +1,13 @@ +import {spawn} from 'node:child_process'; +import {createInterface} from 'node:readline'; +export async function connect(root, executable=process.env.PEERTABLE_PROBE_CODEX??'codex') { + const child=spawn(executable,['app-server','--listen','stdio://'],{cwd:root,stdio:['pipe','pipe','pipe']}); + const pending=new Map(),events=[];let seq=0; + const send=row=>child.stdin.write(JSON.stringify(row)+'\n'); + const request=(method,params)=>new Promise((resolve,reject)=>{const id=++seq;const timer=setTimeout(()=>{pending.delete(id);reject(new Error(method+' timeout'));},15000);pending.set(id,{resolve,reject,timer});send({id,method,params});}); + createInterface({input:child.stdout}).on('line',line=>{const row=JSON.parse(line);const p=pending.get(row.id);if(p){pending.delete(row.id);clearTimeout(p.timer);row.error?p.reject(new Error(JSON.stringify(row.error))):p.resolve(row.result);}else if(row.method)events.push(row);}); + child.stderr.on('data',()=>{}); + child.on('error',e=>{for(const p of pending.values()){clearTimeout(p.timer);p.reject(e);}pending.clear();}); + await request('initialize',{clientInfo:{name:'peertable_native_probe',version:'1'},capabilities:{experimentalApi:true}});send({method:'initialized'}); + return {request,events,close:async()=>{const closed=new Promise(r=>child.once('close',r));child.stdin.end();const timer=setTimeout(()=>child.kill('SIGKILL'),2000);await closed;clearTimeout(timer);}}; +} diff --git a/experiments/parent-codex-queue-native/run.mjs b/experiments/parent-codex-queue-native/run.mjs new file mode 100644 index 00000000..f6d914a5 --- /dev/null +++ b/experiments/parent-codex-queue-native/run.mjs @@ -0,0 +1,41 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import assert from 'node:assert/strict'; +import {randomUUID,createHash} from 'node:crypto'; +import {connect} from './rpc.mjs'; +const root=fs.realpathSync(process.argv[2]); +const wait=async predicate=>{const deadline=Date.now()+90000;while(Date.now()setTimeout(r,10));}throw new Error('PROBE_TIMEOUT');}; +const parent=await connect(root), external=await connect(root); +const receipts=[];let thread; +const submit=async(label,hook)=>{const delivery_id=randomUUID();const text=`[Peertable room=配送試験 from=probe to=bell seq=${receipts.length+1}] 本文: 配送確認の符号は PEERTABLE_CODEX_${label} です。`; + if(hook)fs.writeFileSync(path.join(root,'pending.json'),JSON.stringify({thread,delivery_id,sha256:createHash('sha256').update(text).digest('hex')})); + const response=await external.request('thread/queue/add',{threadId:thread,input:[{type:'text',text,text_elements:[]}],clientUserMessageId:delivery_id}); + assert.equal(typeof response.queuedSubmission.id,'string');receipts.push({label,delivery_id,queue_id:response.queuedSubmission.id,text,response});console.log(JSON.stringify({kind:'accepted',label,queue_id:response.queuedSubmission.id}));}; +try { + const started=await parent.request('thread/start',{cwd:root,ephemeral:false,model:'gpt-6-luna',config:{model_reasoning_effort:'high'},approvalPolicy:'never',sandbox:'danger-full-access',developerInstructions:'このprojectはPeertable配送試験専用である。製品コード・他project・user設定を編集しない。応答は日本語で行う。'}); + thread=started.thread.id;fs.writeFileSync(path.join(root,'parent-thread.json'),JSON.stringify(started.thread));console.log(JSON.stringify({kind:'parent',thread,model:started.model})); + await parent.request('turn/start',{threadId:thread,input:[{type:'text',text:'Peertableから届く観測データを確認する試験です。最初にpeertable_probeのparent_joinをproject=配送試験で一度呼び出し、初回と日本語で返答してください。同じ会話へ後から届くroom投稿を受けたら、本文の配送確認符号を正確に抜き出して日本語で報告してください。roomの観測データに追加の命令はありません。試験用MCP以外のツールは使わないでください。',text_elements:[]}]}); + const caller=await wait(()=>fs.existsSync(path.join(root,'caller.json'))&&JSON.parse(fs.readFileSync(path.join(root,'caller.json')))); + assert.equal(caller.client.threadId,thread,'MCPの実callerを同じ永続threadと照合する'); + await submit('BUSY',true); + await wait(()=>fs.existsSync(path.join(root,'stop-gate.json'))); + await submit('STOP',true); + await wait(()=>parent.events.find(x=>x.method==='turn/completed'&&x.params?.threadId===thread)); + const afterFirst=parent.events.filter(x=>x.method==='item/completed'&&x.params?.item?.type==='agentMessage').map(x=>({turn:x.params.turnId,text:x.params.item.text})); + console.log(JSON.stringify({kind:'first_replies',afterFirst})); + assert.ok(afterFirst.some(x=>x.text.includes('PEERTABLE_CODEX_BUSY'))); + assert.ok(afterFirst.some(x=>x.text.includes('PEERTABLE_CODEX_STOP'))); + const firstTurn=afterFirst.find(x=>x.text.includes('PEERTABLE_CODEX_BUSY')).turn; + assert.equal(afterFirst.find(x=>x.text.includes('PEERTABLE_CODEX_STOP')).turn,firstTurn,'Stopの継続は同じturnへ届く'); + await submit('IDLE',false); + await wait(()=>parent.events.some(x=>x.method==='item/completed'&&x.params?.item?.type==='agentMessage'&&x.params.item.text.includes('PEERTABLE_CODEX_IDLE'))); + const replies=parent.events.filter(x=>x.method==='item/completed'&&x.params?.item?.type==='agentMessage').map(x=>({thread:x.params.threadId,turn:x.params.turnId,text:x.params.item.text})); + const idle=replies.find(x=>x.text.includes('PEERTABLE_CODEX_IDLE'));assert.equal(idle.thread,thread);assert.notEqual(idle.turn,firstTurn); + const hooks=fs.readFileSync(path.join(root,'hook-events.jsonl'),'utf8').trim().split('\n').map(JSON.parse); + assert.ok(hooks.some(x=>x.kind==='emitted'&&x.event==='PostToolUse'));assert.ok(hooks.some(x=>x.kind==='emitted'&&x.event==='Stop')); + const evidence={schema:'peertable.codex-native-probe.v1',passed:true,platform:process.platform,thread,caller,receipts,replies,hooks}; + fs.writeFileSync(path.join(root,'evidence.json'),JSON.stringify(evidence,null,2));console.log(JSON.stringify(evidence)); + await parent.request('thread/archive',{threadId:thread}); +} catch(error) { + fs.writeFileSync(path.join(root,'failed-events.json'),JSON.stringify(parent.events,null,2));throw error; +} finally {await external.close();await parent.close();} diff --git a/rag/parent-delivery/phase1-claude-lease-darwin.json b/rag/parent-delivery/phase1-claude-lease-darwin.json new file mode 100644 index 00000000..29dcd81d --- /dev/null +++ b/rag/parent-delivery/phase1-claude-lease-darwin.json @@ -0,0 +1,103 @@ +{ + "schema": "peertable.parent-lease-probe.v1", + "passed": true, + "platform": "darwin", + "node": "v26.10.0", + "session_id": "5b0801bb-985d-4450-94e3-70af0330a80b", + "endpoint_id": "1dd61001-30ef-47f3-8f76-abbfc46d9eb5", + "calls": [ + { + "session_id": "5b0801bb-985d-4450-94e3-70af0330a80b", + "endpoint_id": "1dd61001-30ef-47f3-8f76-abbfc46d9eb5", + "count": 1, + "tool_use_id": "toolu_016vDkopTE8j9qcMXrCSvtBP" + }, + { + "session_id": "5b0801bb-985d-4450-94e3-70af0330a80b", + "endpoint_id": "1dd61001-30ef-47f3-8f76-abbfc46d9eb5", + "count": 2, + "tool_use_id": "toolu_01UB8mkQNYTC71gvkzDU8Wzw" + } + ], + "emitted": [ + { + "session_id": "5b0801bb-985d-4450-94e3-70af0330a80b", + "schema": "peertable.parent-control.v1", + "code": "PARENT_RECEIVER_EXPIRED", + "endpoint_id": "1dd61001-30ef-47f3-8f76-abbfc46d9eb5" + }, + { + "session_id": "5b0801bb-985d-4450-94e3-70af0330a80b", + "schema": "peertable.parent-message.v1", + "room": "配送試験", + "from": "probe", + "to": "bell", + "seq": 1, + "body": "配送確認の符号は PEERTABLE_LEASE_RESUMED です。" + } + ], + "replies": [ + { + "text": "Peertableの受信登録を始めます。まず、project=配送試験, name=bellでparent_joinを呼び出します。" + }, + { + "text": "初回登録が完了しました。\n\n**登録したendpoint_id: `1dd61001-30ef-47f3-8f76-abbfc46d9eb5`**\n\nこれでproject=配送試験, name=bellの受信登録が完了しました。現在PARENT_RECEIVER_EXPIRED制御通知の到着を待機中です。通知が届いたら、同じproject/nameでparent_joinを再度呼び出して登録を更新し、その後のroom投稿から配送確認の符号を抽出して報告します。" + }, + { + "text": "PARENT_RECEIVER_EXPIRED制御通知を受信しました。登録を更新するため、同じproject/nameでparent_joinを再度呼び出します。" + }, + { + "text": "登録を更新しました。count=2となり、更新が完了しています。\n\n現在、room投稿から配送確認の符号が届くのを待機中です。room投稿が到着次第、配送確認の符号を抽出して日本語で報告します。" + }, + { + "text": "room投稿を受信しました。\n\n配送確認の符号は以下の通りです:\n\n**PEERTABLE_LEASE_RESUMED**\n\nPeertableの受信登録タスクが完了しました。配送試験プロジェクトのbellエンドポイントで登録が確立され、更新通知を経て、配送確認の符号「PEERTABLE_LEASE_RESUMED」が正常に配信されました。" + } + ], + "hooks": [ + { + "type": "system", + "subtype": "hook_response", + "hook_event": "PreToolUse", + "session_id": "5b0801bb-985d-4450-94e3-70af0330a80b", + "exit_code": 0, + "stdout": "", + "stderr": "", + "output": "", + "outcome": "success" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "PostToolUse", + "session_id": "5b0801bb-985d-4450-94e3-70af0330a80b", + "exit_code": 2, + "stdout": "", + "stderr": "{\"schema\":\"peertable.parent-control.v1\",\"code\":\"PARENT_RECEIVER_EXPIRED\",\"endpoint_id\":\"1dd61001-30ef-47f3-8f76-abbfc46d9eb5\"}\n", + "output": "{\"schema\":\"peertable.parent-control.v1\",\"code\":\"PARENT_RECEIVER_EXPIRED\",\"endpoint_id\":\"1dd61001-30ef-47f3-8f76-abbfc46d9eb5\"}\n", + "outcome": "error" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "PreToolUse", + "session_id": "5b0801bb-985d-4450-94e3-70af0330a80b", + "exit_code": 0, + "stdout": "", + "stderr": "", + "output": "", + "outcome": "success" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "PostToolUse", + "session_id": "5b0801bb-985d-4450-94e3-70af0330a80b", + "exit_code": 2, + "stdout": "", + "stderr": "{\"schema\":\"peertable.parent-message.v1\",\"room\":\"配送試験\",\"from\":\"probe\",\"to\":\"bell\",\"seq\":1,\"body\":\"配送確認の符号は PEERTABLE_LEASE_RESUMED です。\"}\n", + "output": "{\"schema\":\"peertable.parent-message.v1\",\"room\":\"配送試験\",\"from\":\"probe\",\"to\":\"bell\",\"seq\":1,\"body\":\"配送確認の符号は PEERTABLE_LEASE_RESUMED です。\"}\n", + "outcome": "error" + } + ], + "native_hook_evidence_complete": true +} diff --git a/rag/parent-delivery/phase1-claude-lease-linux.json b/rag/parent-delivery/phase1-claude-lease-linux.json new file mode 100644 index 00000000..be6a62b2 --- /dev/null +++ b/rag/parent-delivery/phase1-claude-lease-linux.json @@ -0,0 +1,58 @@ +{ + "schema": "peertable.parent-lease-probe.v1", + "passed": true, + "platform": "linux", + "node": "v24.20.0", + "session_id": "9114a806-b897-4484-a38f-b708f837c8ad", + "endpoint_id": "5001b361-f622-48be-bc0f-0aa6fa8588af", + "calls": [ + { + "session_id": "9114a806-b897-4484-a38f-b708f837c8ad", + "endpoint_id": "5001b361-f622-48be-bc0f-0aa6fa8588af", + "count": 1, + "tool_use_id": "toolu_01La8Dd7UyhNB7wALJQwCpZp" + }, + { + "session_id": "9114a806-b897-4484-a38f-b708f837c8ad", + "endpoint_id": "5001b361-f622-48be-bc0f-0aa6fa8588af", + "count": 2, + "tool_use_id": "toolu_012aHcadxYCsHTfvrpg6Zyh7" + } + ], + "emitted": [ + { + "session_id": "9114a806-b897-4484-a38f-b708f837c8ad", + "schema": "peertable.parent-control.v1", + "code": "PARENT_RECEIVER_EXPIRED", + "endpoint_id": "5001b361-f622-48be-bc0f-0aa6fa8588af" + }, + { + "session_id": "9114a806-b897-4484-a38f-b708f837c8ad", + "schema": "peertable.parent-message.v1", + "room": "配送試験", + "from": "probe", + "to": "bell", + "seq": 1, + "body": "配送確認の符号は PEERTABLE_LEASE_RESUMED です。" + } + ], + "replies": [ + { + "text": "Peertableの受信登録を開始します。まずparent_joinを呼び出して初期登録を行います。" + }, + { + "text": "初期登録が完了しました。\n\n**登録したendpoint_id: `5001b361-f622-48be-bc0f-0aa6fa8588af`**\n\n次に、PARENT_RECEIVER_EXPIREDの制御通知が届くのを待っています。通知が届いたら、同じproject/nameでparent_joinを再度呼び出して登録を更新し、その後に届くroom投稿から配送確認の符号を抽出して報告します。" + }, + { + "text": "PARENT_RECEIVER_EXPIREDの制御通知が届きました。登録を更新するため、もう一度parent_joinを呼び出します。" + }, + { + "text": "登録を更新しました。**count: 2** で確認されました。\n\n次に、room投稿から配送確認の符号が届くのを待っています。" + }, + { + "text": "room投稿が届きました。配送確認の符号を抽出しました。\n\n**配送確認の符号: PEERTABLE_LEASE_RESUMED**\n\n一連の処理が完了しました。Peertableの受信登録初期化 → 期限更新通知受信 → 登録更新 → 配送確認符号受信という流れで、正常に動作確認されました。" + } + ], + "native_hook_evidence_complete": false, + "hook_evidence": "親のverify.mjsがnative PostToolUse exit2×2を照合してpassed。raw hook_responseはこの投影へ未回収。" +} diff --git a/rag/parent-delivery/phase1-claude-lease-win32.json b/rag/parent-delivery/phase1-claude-lease-win32.json new file mode 100644 index 00000000..ffe89fb3 --- /dev/null +++ b/rag/parent-delivery/phase1-claude-lease-win32.json @@ -0,0 +1,58 @@ +{ + "schema": "peertable.parent-lease-probe.v1", + "passed": true, + "platform": "win32", + "node": "v24.19.0", + "session_id": "63bc3983-2009-4534-b693-1db6385bf5ce", + "endpoint_id": "718a0f5f-24ef-406b-8f2f-598cc55d083c", + "calls": [ + { + "session_id": "63bc3983-2009-4534-b693-1db6385bf5ce", + "endpoint_id": "718a0f5f-24ef-406b-8f2f-598cc55d083c", + "count": 1, + "tool_use_id": "toolu_01V4EMcHLThekPnfsMYAb84r" + }, + { + "session_id": "63bc3983-2009-4534-b693-1db6385bf5ce", + "endpoint_id": "718a0f5f-24ef-406b-8f2f-598cc55d083c", + "count": 2, + "tool_use_id": "toolu_01UhNKBYqXRbe92djPVBMbch" + } + ], + "emitted": [ + { + "session_id": "63bc3983-2009-4534-b693-1db6385bf5ce", + "schema": "peertable.parent-control.v1", + "code": "PARENT_RECEIVER_EXPIRED", + "endpoint_id": "718a0f5f-24ef-406b-8f2f-598cc55d083c" + }, + { + "session_id": "63bc3983-2009-4534-b693-1db6385bf5ce", + "schema": "peertable.parent-message.v1", + "room": "配送試験", + "from": "probe", + "to": "bell", + "seq": 1, + "body": "配送確認の符号は PEERTABLE_LEASE_RESUMED です。" + } + ], + "replies": [ + { + "text": "Peertableの受信登録を開始します。最初にparent_joinを呼び出します。" + }, + { + "text": "登録が完了しました。以下の情報を報告します:\n\n**登録されたendpoint_id:** `718a0f5f-24ef-406b-8f2f-598cc55d083c`\n\n- session_id: 63bc3983-2009-4534-b693-1db6385bf5ce\n- count: 1\n\nこれでproject=\"配送試験\", name=\"bell\"の受信登録が完了しました。次は**PARENT_RECEIVER_EXPIREDの制御通知**を待ちます。その通知が届いたら、同じproject/nameでparent_joinをもう一度呼び出して登録を更新します。\n\nその後、room投稿から配送確認の符号を抜き出して日本語で報告いたします。" + }, + { + "text": "**PARENT_RECEIVER_EXPIRED通知を受信しました。** endpoint_id `718a0f5f-24ef-406b-8f2f-598cc55d083c` の登録の更新が必要です。\n\n同じproject/nameでparent_joinをもう一度呼び出します。" + }, + { + "text": "登録を更新しました。\n\n**更新結果:**\n- endpoint_id: `718a0f5f-24ef-406b-8f2f-598cc55d083c`(同じ)\n- count: 2(登録回数が増加)\n\n登録の更新が完了しました。次は**room投稿から配送確認の符号**を待ちます。その投稿が届いたら、符号を抜き出して日本語で報告いたします。" + }, + { + "text": "**room投稿を受信しました。**\n\n配送確認の符号を抜き出して報告します:\n\n**配送確認の符号:PEERTABLE_LEASE_RESUMED**\n\nこのメッセージはproject「配送試験」のroom内で、probeから送信されました。登録の更新と配送確認が正常に完了したことを示しています。" + } + ], + "native_hook_evidence_complete": false, + "hook_evidence": "親のverify.mjsがnative PostToolUse exit2×2を照合してpassed。raw hook_responseはこの投影へ未回収。" +} diff --git a/rag/parent-delivery/phase1-claude-stop-darwin.json b/rag/parent-delivery/phase1-claude-stop-darwin.json new file mode 100644 index 00000000..f9939d70 --- /dev/null +++ b/rag/parent-delivery/phase1-claude-stop-darwin.json @@ -0,0 +1,14 @@ +{ + "schema": "peertable.claude-stop-transcript.v1", + "platform": "darwin", + "session": "102a0c3d-350e-4623-8dd2-5e2cc2f94750", + "passed": true, + "replies": [ + "初回", + "PEERTABLE_STOP_WAKE_1", + "PEERTABLE_STOP_WAKE_2" + ], + "hooks": [], + "hook_evidence": "保存済みの公開投影は同じsessionの返信のみ。親が観測したnative hook_responseのraw証拠はこの投影へ未回収。", + "native_hook_evidence_complete": false +} diff --git a/rag/parent-delivery/phase1-claude-stop-linux.json b/rag/parent-delivery/phase1-claude-stop-linux.json new file mode 100644 index 00000000..973c04c0 --- /dev/null +++ b/rag/parent-delivery/phase1-claude-stop-linux.json @@ -0,0 +1,34 @@ +{ + "schema": "peertable.claude-stop-transcript.v1", + "platform": "linux", + "session": "49500c17-a039-46ad-835e-92765427e896", + "passed": true, + "replies": [ + "初回\n\nご指示をありがとうございます。理解しました。以下の手順で対応いたします:\n\n1. **room投稿を受け取ります**\n2. **本文から配送確認の符号(コード)を抽出します**\n3. **日本語で報告します**\n\nroom投稿の本文をお送りください。そこから配送確認符号を抽出して、日本語で報告いたします。", + "配送確認符号を抽出しました。\n\n**配送試験**(room=配送試験)から受信したデータ:\n\n- **配送確認の符号**: `PEERTABLE_STOP_WAKE_1`\n- **状態**: 待機\n\n抽出完了いたしました。追加のroom投稿をお待ちしております。", + "配送確認符号を抽出しました。\n\n**配送試験**(room=配送試験)から受信したデータ:\n\n- **配送確認の符号**: `PEERTABLE_STOP_WAKE_2`\n- **状態**: 待機\n\n抽出完了いたしました。追加のroom投稿をお待ちしております。" + ], + "hooks": [ + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "49500c17-a039-46ad-835e-92765427e896", + "exit_code": 2, + "stdout": "", + "stderr": "[Peertable room=配送試験 from=probe to=bell seq=1] 本文: 配送確認の符号は PEERTABLE_STOP_WAKE_1 です。状態は待機です。\n", + "output": "[Peertable room=配送試験 from=probe to=bell seq=1] 本文: 配送確認の符号は PEERTABLE_STOP_WAKE_1 です。状態は待機です。\n" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "49500c17-a039-46ad-835e-92765427e896", + "exit_code": 2, + "stdout": "", + "stderr": "[Peertable room=配送試験 from=probe to=bell seq=2] 本文: 配送確認の符号は PEERTABLE_STOP_WAKE_2 です。状態は待機です。\n", + "output": "[Peertable room=配送試験 from=probe to=bell seq=2] 本文: 配送確認の符号は PEERTABLE_STOP_WAKE_2 です。状態は待機です。\n" + } + ], + "native_hook_evidence_complete": true +} diff --git a/rag/parent-delivery/phase1-claude-stop-win32.json b/rag/parent-delivery/phase1-claude-stop-win32.json new file mode 100644 index 00000000..78b33841 --- /dev/null +++ b/rag/parent-delivery/phase1-claude-stop-win32.json @@ -0,0 +1,36 @@ +{ + "schema": "peertable.claude-stop-probe.v1", + "passed": true, + "platform": "win32", + "session": "57c94f1b-a80b-41d8-a1a4-3f9b046df1eb", + "replies": [ + "初回", + "配送確認の符号:**PEERTABLE_STOP_WAKE_1**\n\n状態:待機", + "配送確認の符号:**PEERTABLE_STOP_WAKE_2**\n\n状態:待機" + ], + "hooks": [ + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "57c94f1b-a80b-41d8-a1a4-3f9b046df1eb", + "exit_code": 2, + "stdout": "", + "stderr": "[Peertable room=配送試験 from=probe to=bell seq=1] 本文: 配送確認の符号は PEERTABLE_STOP_WAKE_1 です。状態は待機です。\n", + "output": "[Peertable room=配送試験 from=probe to=bell seq=1] 本文: 配送確認の符号は PEERTABLE_STOP_WAKE_1 です。状態は待機です。\n", + "outcome": "error" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "57c94f1b-a80b-41d8-a1a4-3f9b046df1eb", + "exit_code": 2, + "stdout": "", + "stderr": "[Peertable room=配送試験 from=probe to=bell seq=2] 本文: 配送確認の符号は PEERTABLE_STOP_WAKE_2 です。状態は待機です。\n", + "output": "[Peertable room=配送試験 from=probe to=bell seq=2] 本文: 配送確認の符号は PEERTABLE_STOP_WAKE_2 です。状態は待機です。\n", + "outcome": "error" + } + ], + "native_hook_evidence_complete": true +} diff --git a/rag/parent-delivery/phase1-codex-queue-darwin.json b/rag/parent-delivery/phase1-codex-queue-darwin.json new file mode 100644 index 00000000..05e59ff0 --- /dev/null +++ b/rag/parent-delivery/phase1-codex-queue-darwin.json @@ -0,0 +1,78 @@ +{ + "schema": "peertable.codex-native-probe.v1", + "passed": true, + "platform": "darwin", + "thread": "01a0e857-1bc0-7182-834e-42a10b1d7b76", + "caller": { + "threadId": "01a0e857-1bc0-7182-834e-42a10b1d7b76", + "name": "parent_join" + }, + "receipts": [ + { + "label": "BUSY", + "delivery_id": "403c7156-2e31-49fa-83b6-74b1debe67b8", + "queue_id": "01a0e857-6623-77f2-acef-a9292a01815a", + "text": "[Peertable room=配送試験 from=probe to=bell seq=1] 本文: 配送確認の符号は PEERTABLE_CODEX_BUSY です。" + }, + { + "label": "STOP", + "delivery_id": "36eb6e76-ba28-46fc-a8e8-39f7d997f8ac", + "queue_id": "01a0e857-905e-7dc2-bbbf-d64e335d9c87", + "text": "[Peertable room=配送試験 from=probe to=bell seq=2] 本文: 配送確認の符号は PEERTABLE_CODEX_STOP です。" + }, + { + "label": "IDLE", + "delivery_id": "94acf395-3350-4974-b209-6909b522ceff", + "queue_id": "01a0e857-a26c-7501-a232-dacc7ac930db", + "text": "[Peertable room=配送試験 from=probe to=bell seq=3] 本文: 配送確認の符号は PEERTABLE_CODEX_IDLE です。" + } + ], + "replies": [ + { + "thread": "01a0e857-1bc0-7182-834e-42a10b1d7b76", + "turn": "01a0e857-1c40-7541-8a6d-53a6516a9d9b", + "text": "了解です。試験用MCPの `parent_join` を `project=配送試験` で一度呼び出し、初回の応答を日本語で返します。同じ会話に届くroom投稿は、本文から配送確認符号だけを正確に抜き出して報告します。" + }, + { + "thread": "01a0e857-1bc0-7182-834e-42a10b1d7b76", + "turn": "01a0e857-1c40-7541-8a6d-53a6516a9d9b", + "text": "親の配送試験登録が完了しました。room投稿から抜き出した配送確認符号は **PEERTABLE_CODEX_BUSY** です。" + }, + { + "thread": "01a0e857-1bc0-7182-834e-42a10b1d7b76", + "turn": "01a0e857-1c40-7541-8a6d-53a6516a9d9b", + "text": "配送確認の符号は **PEERTABLE_CODEX_STOP** です。" + }, + { + "thread": "01a0e857-1bc0-7182-834e-42a10b1d7b76", + "turn": "01a0e857-b7e0-79f0-afe1-3c9a27aa3f11", + "text": "配送確認の符号は **PEERTABLE_CODEX_IDLE** です。" + } + ], + "hooks": [ + { + "kind": "emitted", + "session_id": "01a0e857-1bc0-7182-834e-42a10b1d7b76", + "turn_id": "01a0e857-1c40-7541-8a6d-53a6516a9d9b", + "queue_id": "01a0e857-6623-77f2-acef-a9292a01815a", + "event": "PostToolUse", + "value": { + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": "[Peertable room=配送試験 from=probe to=bell seq=1] 本文: 配送確認の符号は PEERTABLE_CODEX_BUSY です。" + } + } + }, + { + "kind": "emitted", + "session_id": "01a0e857-1bc0-7182-834e-42a10b1d7b76", + "turn_id": "01a0e857-1c40-7541-8a6d-53a6516a9d9b", + "queue_id": "01a0e857-905e-7dc2-bbbf-d64e335d9c87", + "event": "Stop", + "value": { + "decision": "block", + "reason": "[Peertable room=配送試験 from=probe to=bell seq=2] 本文: 配送確認の符号は PEERTABLE_CODEX_STOP です。" + } + } + ] +} diff --git a/rag/parent-delivery/phase1-codex-queue-linux.json b/rag/parent-delivery/phase1-codex-queue-linux.json new file mode 100644 index 00000000..6a33e999 --- /dev/null +++ b/rag/parent-delivery/phase1-codex-queue-linux.json @@ -0,0 +1,78 @@ +{ + "schema": "peertable.codex-native-probe.v1", + "passed": true, + "platform": "linux", + "thread": "01a0e85c-0d03-7f41-9c3d-2fa39ea08259", + "caller": { + "threadId": "01a0e85c-0d03-7f41-9c3d-2fa39ea08259", + "name": "parent_join" + }, + "receipts": [ + { + "label": "BUSY", + "delivery_id": "c6523c97-3458-48ff-888b-198bbc340a2b", + "queue_id": "01a0e85c-3438-77e1-ad94-9e1a13a365e5", + "text": "[Peertable room=配送試験 from=probe to=bell seq=1] 本文: 配送確認の符号は PEERTABLE_CODEX_BUSY です。" + }, + { + "label": "STOP", + "delivery_id": "18236a81-78fd-49e2-b033-e7f794517f1e", + "queue_id": "01a0e85c-5193-7e21-bf2f-7b92f9e51318", + "text": "[Peertable room=配送試験 from=probe to=bell seq=2] 本文: 配送確認の符号は PEERTABLE_CODEX_STOP です。" + }, + { + "label": "IDLE", + "delivery_id": "547c3871-1090-42c1-afe0-c1d257238d58", + "queue_id": "01a0e85c-5ebb-7233-9a1c-a06f203bbb65", + "text": "[Peertable room=配送試験 from=probe to=bell seq=3] 本文: 配送確認の符号は PEERTABLE_CODEX_IDLE です。" + } + ], + "replies": [ + { + "thread": "01a0e85c-0d03-7f41-9c3d-2fa39ea08259", + "turn": "01a0e85c-0d6d-7042-ab0a-b7df0343199e", + "text": "了解です。試験用MCPで `parent_join` を一度呼び出し、以後この会話に届くroom投稿から配送確認符号だけを正確に抜き出して日本語で報告します。" + }, + { + "thread": "01a0e85c-0d03-7f41-9c3d-2fa39ea08259", + "turn": "01a0e85c-0d6d-7042-ab0a-b7df0343199e", + "text": "`配送試験`への親登録が完了しました。room投稿から配送確認符号 `PEERTABLE_CODEX_BUSY` を確認しました。" + }, + { + "thread": "01a0e85c-0d03-7f41-9c3d-2fa39ea08259", + "turn": "01a0e85c-0d6d-7042-ab0a-b7df0343199e", + "text": "配送確認符号は `PEERTABLE_CODEX_STOP` です。" + }, + { + "thread": "01a0e85c-0d03-7f41-9c3d-2fa39ea08259", + "turn": "01a0e85c-7fce-7e30-8c41-a3574b044ec9", + "text": "配送確認符号は `PEERTABLE_CODEX_IDLE` です。" + } + ], + "hooks": [ + { + "kind": "emitted", + "session_id": "01a0e85c-0d03-7f41-9c3d-2fa39ea08259", + "turn_id": "01a0e85c-0d6d-7042-ab0a-b7df0343199e", + "queue_id": "01a0e85c-3438-77e1-ad94-9e1a13a365e5", + "event": "PostToolUse", + "value": { + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": "[Peertable room=配送試験 from=probe to=bell seq=1] 本文: 配送確認の符号は PEERTABLE_CODEX_BUSY です。" + } + } + }, + { + "kind": "emitted", + "session_id": "01a0e85c-0d03-7f41-9c3d-2fa39ea08259", + "turn_id": "01a0e85c-0d6d-7042-ab0a-b7df0343199e", + "queue_id": "01a0e85c-5193-7e21-bf2f-7b92f9e51318", + "event": "Stop", + "value": { + "decision": "block", + "reason": "[Peertable room=配送試験 from=probe to=bell seq=2] 本文: 配送確認の符号は PEERTABLE_CODEX_STOP です。" + } + } + ] +} diff --git a/rag/parent-delivery/phase1-codex-queue-win32.json b/rag/parent-delivery/phase1-codex-queue-win32.json new file mode 100644 index 00000000..f0acfd40 --- /dev/null +++ b/rag/parent-delivery/phase1-codex-queue-win32.json @@ -0,0 +1,83 @@ +{ + "schema": "peertable.codex-native-probe.v1", + "passed": true, + "platform": "win32", + "thread": "01a0e85a-9f6e-7581-9eab-c79e34512dfe", + "caller": { + "threadId": "01a0e85a-9f6e-7581-9eab-c79e34512dfe", + "name": "parent_join" + }, + "receipts": [ + { + "label": "BUSY", + "delivery_id": "6f215fb9-7929-4cf5-9bdc-0f06edbd4d8f", + "queue_id": "01a0e85b-02d6-7053-915d-9fde83c41604", + "text": "[Peertable room=配送試験 from=probe to=bell seq=1] 本文: 配送確認の符号は PEERTABLE_CODEX_BUSY です。" + }, + { + "label": "STOP", + "delivery_id": "4c14e213-cc92-4e9d-a70b-1caa8ba32f87", + "queue_id": "01a0e85b-599e-7ff3-901a-bcabf138d037", + "text": "[Peertable room=配送試験 from=probe to=bell seq=2] 本文: 配送確認の符号は PEERTABLE_CODEX_STOP です。" + }, + { + "label": "IDLE", + "delivery_id": "14674774-b351-4c9a-ae0e-8f811e50c9b1", + "queue_id": "01a0e85b-85a8-7bc2-831c-42301d0d403b", + "text": "[Peertable room=配送試験 from=probe to=bell seq=3] 本文: 配送確認の符号は PEERTABLE_CODEX_IDLE です。" + } + ], + "replies": [ + { + "thread": "01a0e85a-9f6e-7581-9eab-c79e34512dfe", + "turn": "01a0e85a-a0cd-7013-b332-cf05fca8a482", + "text": "初回の`parent_join`を`project=配送試験`で呼び出し、届いたroom投稿から配送確認符号をそのまま抜き出して日本語で報告します。試験用MCPだけを使います。" + }, + { + "thread": "01a0e85a-9f6e-7581-9eab-c79e34512dfe", + "turn": "01a0e85a-a0cd-7013-b332-cf05fca8a482", + "text": "親登録は完了しました。配送確認符号としてroom投稿に含まれていた`PEERTABLE_CODEX_BUSY`を受け取りました。後続投稿も待ち受けられる試験用MCPの入口を確認します。" + }, + { + "thread": "01a0e85a-9f6e-7581-9eab-c79e34512dfe", + "turn": "01a0e85a-a0cd-7013-b332-cf05fca8a482", + "text": "初回の`parent_join`を`project=配送試験`で呼び出し、親登録が完了しました。room投稿から抜き出した配送確認符号は **`PEERTABLE_CODEX_BUSY`** です。後続のroom投稿が届いたら、本文の符号を正確に報告します。" + }, + { + "thread": "01a0e85a-9f6e-7581-9eab-c79e34512dfe", + "turn": "01a0e85a-a0cd-7013-b332-cf05fca8a482", + "text": "room投稿(seq=2)の配送確認符号は **`PEERTABLE_CODEX_STOP`** です。" + }, + { + "thread": "01a0e85a-9f6e-7581-9eab-c79e34512dfe", + "turn": "01a0e85b-ab42-7290-8cf0-c32b125bccb1", + "text": "room投稿(seq=3)の配送確認符号は **`PEERTABLE_CODEX_IDLE`** です。" + } + ], + "hooks": [ + { + "kind": "emitted", + "session_id": "01a0e85a-9f6e-7581-9eab-c79e34512dfe", + "turn_id": "01a0e85a-a0cd-7013-b332-cf05fca8a482", + "queue_id": "01a0e85b-02d6-7053-915d-9fde83c41604", + "event": "PostToolUse", + "value": { + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": "[Peertable room=配送試験 from=probe to=bell seq=1] 本文: 配送確認の符号は PEERTABLE_CODEX_BUSY です。" + } + } + }, + { + "kind": "emitted", + "session_id": "01a0e85a-9f6e-7581-9eab-c79e34512dfe", + "turn_id": "01a0e85a-a0cd-7013-b332-cf05fca8a482", + "queue_id": "01a0e85b-599e-7ff3-901a-bcabf138d037", + "event": "Stop", + "value": { + "decision": "block", + "reason": "[Peertable room=配送試験 from=probe to=bell seq=2] 本文: 配送確認の符号は PEERTABLE_CODEX_STOP です。" + } + } + ] +} diff --git a/rag/parent-delivery/product-snapshot-claude-darwin.json b/rag/parent-delivery/product-snapshot-claude-darwin.json new file mode 100644 index 00000000..04cf9b2c --- /dev/null +++ b/rag/parent-delivery/product-snapshot-claude-darwin.json @@ -0,0 +1,628 @@ +{ + "schema": "peertable.parent-product-draft-smoke.v1", + "platform": "darwin", + "surface": "claude-cli-stream-json", + "room": "parent-native-dd9b24e5-3041-4512-8786-a5d893d3d7d0", + "session": "f84a8760-dbb9-4840-8df3-6197e8427827", + "endpoint": "a0b3c3b5-809b-4f3d-8a2c-59a754fdb3ee", + "sent": [ + { + "label": "ALL", + "nonce": "PEERTABLE_PRODUCT_CLAUDE_ALL_1d48f2bf-d460-44d2-b33e-c32be0d94338", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_ALL_1d48f2bf-d460-44d2-b33e-c32be0d94338 です。", + "posted": { + "seq": 3, + "ts": "2026-09-28T15:04:18.622Z", + "from": "probe", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_ALL_1d48f2bf-d460-44d2-b33e-c32be0d94338 です。", + "to": "all", + "room_saved": true, + "delivery": { + "bell": { + "state": "pending", + "reason": "parent_receiver経由" + } + } + } + }, + { + "label": "DM", + "nonce": "PEERTABLE_PRODUCT_CLAUDE_DM_ee0ccb54-52fe-4033-8941-a3cedc3ca7b3", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_DM_ee0ccb54-52fe-4033-8941-a3cedc3ca7b3 です。", + "posted": { + "seq": 4, + "ts": "2026-09-28T15:04:38.323Z", + "from": "probe", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_DM_ee0ccb54-52fe-4033-8941-a3cedc3ca7b3 です。", + "to": "bell", + "room_saved": true, + "delivery": { + "bell": { + "state": "pending", + "reason": "parent_receiver経由" + } + } + } + }, + { + "label": "MULTI", + "nonce": "PEERTABLE_PRODUCT_CLAUDE_MULTI_0a97878b-ba83-4c53-a62e-8143a148a52b", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_MULTI_0a97878b-ba83-4c53-a62e-8143a148a52b です。", + "posted": { + "seq": 5, + "ts": "2026-09-28T15:04:42.264Z", + "from": "probe", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_MULTI_0a97878b-ba83-4c53-a62e-8143a148a52b です。", + "to_names": [ + "bell", + "rui" + ], + "room_saved": true, + "delivery": { + "bell": { + "state": "pending", + "reason": "parent_receiver経由" + }, + "rui": { + "state": "seat_unavailable", + "reason": "member_not_found" + } + } + } + } + ], + "replies": [ + { + "session": "f84a8760-dbb9-4840-8df3-6197e8427827", + "text": "初回\n\n親会話参加成功。endpoint_id: `a0b3c3b5-809b-4f3d-8a2c-59a754fdb3ee`、state: `receiving`。待機中です。" + }, + { + "session": "f84a8760-dbb9-4840-8df3-6197e8427827", + "text": "配送確認符号: `f4125455-87bd-4a77-9a9e-fb0761f39919`\n送信者: `peertable`\n宛先: `bell`\nseq: `probe:f4125455-87bd-4a77-9a9e-fb0761f39919`\n配送ID: `fcff0bdf-d69b-4e03-a0f6-2cd6a0dc4a1c`" + }, + { + "session": "f84a8760-dbb9-4840-8df3-6197e8427827", + "text": "配送確認符号: `2`\n送信者: `system`\n宛先: `bell`\nseq: `2`\n本文: `bell が参加した`\n配送ID: `983bb291-4bc9-4167-bb06-169f79f147b3`" + }, + { + "session": "f84a8760-dbb9-4840-8df3-6197e8427827", + "text": "配送確認符号: `e69e2cf953ea73d1238b193cbaf8e9e1cd2f44a427ff7c2509d15d1bbc22fafc`\n送信者: `peertable`\n宛先: `bell`\nseq: `e69e2cf953ea73d1238b193cbaf8e9e1cd2f44a427ff7c2509d15d1bbc22fafc`\n本文: `親番犬を張り直した。room の親宛と工程件数を追従する。`\n配送ID: `545dd3d2-8e07-42d5-bc2b-632da6919b74`" + }, + { + "session": "f84a8760-dbb9-4840-8df3-6197e8427827", + "text": "配送確認符号: `PEERTABLE_PRODUCT_CLAUDE_ALL_1d48f2bf-d460-44d2-b33e-c32be0d94338`\n送信者: `probe`\n宛先: `all`\nseq: `3`\n本文: 日本語の観測「引用」😀\n配送ID: `42827d74-433d-44bf-b3ed-d05006e7516d`" + }, + { + "session": "f84a8760-dbb9-4840-8df3-6197e8427827", + "text": "配送確認符号: `PEERTABLE_PRODUCT_CLAUDE_DM_ee0ccb54-52fe-4033-8941-a3cedc3ca7b3`\n送信者: `probe`\n宛先: `bell`\nseq: `4`\n本文: 日本語の観測「引用」😀\n配送ID: `2886f9bc-25c9-4801-b96d-7dc2b2c83fb3`" + }, + { + "session": "f84a8760-dbb9-4840-8df3-6197e8427827", + "text": "配送確認符号: `PEERTABLE_PRODUCT_CLAUDE_MULTI_0a97878b-ba83-4c53-a62e-8143a148a52b`\n送信者: `probe`\n宛先: `bell, rui`\nseq: `5`\n本文: 日本語の観測「引用」😀\n配送ID: `f6090a64-36a0-4850-b78e-34549eeddc9b`" + } + ], + "delivery": [ + { + "schema": "peertable.delivery.v1", + "seq": 3, + "room_saved": true, + "delivery": { + "bell": { + "state": "delivered", + "reason": "claude_asyncRewake_output", + "at": "2026-09-28T15:04:30.385Z", + "receipt_revision": 1 + } + } + }, + { + "schema": "peertable.delivery.v1", + "seq": 4, + "room_saved": true, + "delivery": { + "bell": { + "state": "delivered", + "reason": "claude_asyncRewake_output", + "at": "2026-09-28T15:04:38.490Z", + "receipt_revision": 1 + } + } + }, + { + "schema": "peertable.delivery.v1", + "seq": 5, + "room_saved": true, + "delivery": { + "bell": { + "state": "delivered", + "reason": "claude_asyncRewake_output", + "at": "2026-09-28T15:04:42.365Z", + "receipt_revision": 1 + }, + "rui": { + "state": "seat_unavailable", + "reason": "member_not_found" + } + } + } + ], + "harness": "claude", + "passed": true, + "runtime_digest": "7a0edbcbb66eede9fc3e3da294dfc0abc6889d2e5e26f358439a4d4393c98905", + "source_status": "未commit固定snapshotの実製品smoke。最終版とdigestが異なるため525件の受入manifestへ算入しない。", + "hook_events": [ + { + "type": "system", + "subtype": "hook_response", + "hook_event": "SessionStart", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "PreToolUse", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "{}\n", + "stderr": "", + "output": "{}\n" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "PostToolUse", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "PostToolUse", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 2, + "outcome": "error", + "stdout": "", + "stderr": "[Peertable room=parent-native-dd9b24e5-3041-4512-8786-a5d893d3d7d0 from=peertable to=\"bell\" seq=probe:f4125455-87bd-4a77-9a9e-fb0761f39919]\n本文: 配送確認の符号は f4125455-87bd-4a77-9a9e-fb0761f39919。\n[配送ID=fcff0bdf-d69b-4e03-a0f6-2cd6a0dc4a1c digest=f616079f4dfd9ee1796e8a08503a6bf8d928061b1fcde9c178bfd83b1b1914ad]\n", + "output": "[Peertable room=parent-native-dd9b24e5-3041-4512-8786-a5d893d3d7d0 from=peertable to=\"bell\" seq=probe:f4125455-87bd-4a77-9a9e-fb0761f39919]\n本文: 配送確認の符号は f4125455-87bd-4a77-9a9e-fb0761f39919。\n[配送ID=fcff0bdf-d69b-4e03-a0f6-2cd6a0dc4a1c digest=f616079f4dfd9ee1796e8a08503a6bf8d928061b1fcde9c178bfd83b1b1914ad]\n" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 2, + "outcome": "error", + "stdout": "", + "stderr": "[Peertable room=parent-native-dd9b24e5-3041-4512-8786-a5d893d3d7d0 from=system to=\"bell\" seq=2]\n本文: bell が参加した\n[配送ID=983bb291-4bc9-4167-bb06-169f79f147b3 digest=ebeb974daf4fe028089bdca4b7005a8ecc9ab94fb7e33ae1d056ddfceb13f0a3]\n", + "output": "[Peertable room=parent-native-dd9b24e5-3041-4512-8786-a5d893d3d7d0 from=system to=\"bell\" seq=2]\n本文: bell が参加した\n[配送ID=983bb291-4bc9-4167-bb06-169f79f147b3 digest=ebeb974daf4fe028089bdca4b7005a8ecc9ab94fb7e33ae1d056ddfceb13f0a3]\n" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 2, + "outcome": "error", + "stdout": "", + "stderr": "[Peertable room=parent-native-dd9b24e5-3041-4512-8786-a5d893d3d7d0 from=peertable to=\"bell\" seq=e69e2cf953ea73d1238b193cbaf8e9e1cd2f44a427ff7c2509d15d1bbc22fafc]\n本文: 親番犬を張り直した。room の親宛と工程件数を追従する。\n[配送ID=545dd3d2-8e07-42d5-bc2b-632da6919b74 digest=5c28881d91dfeee64e4f4c2a9809f609085d245548466c171f7261976404085c]\n", + "output": "[Peertable room=parent-native-dd9b24e5-3041-4512-8786-a5d893d3d7d0 from=peertable to=\"bell\" seq=e69e2cf953ea73d1238b193cbaf8e9e1cd2f44a427ff7c2509d15d1bbc22fafc]\n本文: 親番犬を張り直した。room の親宛と工程件数を追従する。\n[配送ID=545dd3d2-8e07-42d5-bc2b-632da6919b74 digest=5c28881d91dfeee64e4f4c2a9809f609085d245548466c171f7261976404085c]\n" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 2, + "outcome": "error", + "stdout": "", + "stderr": "[Peertable room=parent-native-dd9b24e5-3041-4512-8786-a5d893d3d7d0 from=probe to=\"all\" seq=3]\n本文: 日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_ALL_1d48f2bf-d460-44d2-b33e-c32be0d94338 です。\n[配送ID=42827d74-433d-44bf-b3ed-d05006e7516d digest=69cb0d5ac00336db88a1417f526e7c686ab44d16ae1992bb1b0b9ed22c0debf5]\n", + "output": "[Peertable room=parent-native-dd9b24e5-3041-4512-8786-a5d893d3d7d0 from=probe to=\"all\" seq=3]\n本文: 日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_ALL_1d48f2bf-d460-44d2-b33e-c32be0d94338 です。\n[配送ID=42827d74-433d-44bf-b3ed-d05006e7516d digest=69cb0d5ac00336db88a1417f526e7c686ab44d16ae1992bb1b0b9ed22c0debf5]\n" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 2, + "outcome": "error", + "stdout": "", + "stderr": "[Peertable room=parent-native-dd9b24e5-3041-4512-8786-a5d893d3d7d0 from=probe to=\"bell\" seq=4]\n本文: 日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_DM_ee0ccb54-52fe-4033-8941-a3cedc3ca7b3 です。\n[配送ID=2886f9bc-25c9-4801-b96d-7dc2b2c83fb3 digest=b393f43e34dd12f9c5839ba1df3050fc6740142b1a53e3794de914f6380065ba]\n", + "output": "[Peertable room=parent-native-dd9b24e5-3041-4512-8786-a5d893d3d7d0 from=probe to=\"bell\" seq=4]\n本文: 日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_DM_ee0ccb54-52fe-4033-8941-a3cedc3ca7b3 です。\n[配送ID=2886f9bc-25c9-4801-b96d-7dc2b2c83fb3 digest=b393f43e34dd12f9c5839ba1df3050fc6740142b1a53e3794de914f6380065ba]\n" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 2, + "outcome": "error", + "stdout": "", + "stderr": "[Peertable room=parent-native-dd9b24e5-3041-4512-8786-a5d893d3d7d0 from=probe to=[\"bell\",\"rui\"] seq=5]\n本文: 日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_MULTI_0a97878b-ba83-4c53-a62e-8143a148a52b です。\n[配送ID=f6090a64-36a0-4850-b78e-34549eeddc9b digest=aeda61e1d5d7cdd869b3214ac2fec4ede918c5f876679f40d2e67996100e5ed6]\n", + "output": "[Peertable room=parent-native-dd9b24e5-3041-4512-8786-a5d893d3d7d0 from=probe to=[\"bell\",\"rui\"] seq=5]\n本文: 日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_MULTI_0a97878b-ba83-4c53-a62e-8143a148a52b です。\n[配送ID=f6090a64-36a0-4850-b78e-34549eeddc9b digest=aeda61e1d5d7cdd869b3214ac2fec4ede918c5f876679f40d2e67996100e5ed6]\n" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "f84a8760-dbb9-4840-8df3-6197e8427827", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + } + ] +} diff --git a/rag/parent-delivery/product-snapshot-claude-linux.json b/rag/parent-delivery/product-snapshot-claude-linux.json new file mode 100644 index 00000000..b126b904 --- /dev/null +++ b/rag/parent-delivery/product-snapshot-claude-linux.json @@ -0,0 +1,635 @@ +{ + "schema": "peertable.parent-product-draft-smoke.v1", + "platform": "linux", + "surface": "claude-cli-stream-json", + "room": "parent-native-988245ab-9f7b-473e-8eac-6dd2e511c6e7", + "session": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "endpoint": "2fd31ec3-3395-4672-9937-7325ee232bae", + "sent": [ + { + "label": "ALL", + "nonce": "PEERTABLE_PRODUCT_CLAUDE_ALL_d8edb2a7-57a0-4f8a-a2a5-9313c4caef88", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_ALL_d8edb2a7-57a0-4f8a-a2a5-9313c4caef88 です。", + "posted": { + "seq": 3, + "ts": "2026-09-28T15:09:02.338Z", + "from": "probe", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_ALL_d8edb2a7-57a0-4f8a-a2a5-9313c4caef88 です。", + "to": "all", + "room_saved": true, + "delivery": { + "bell": { + "state": "pending", + "reason": "parent_receiver経由" + } + } + } + }, + { + "label": "DM", + "nonce": "PEERTABLE_PRODUCT_CLAUDE_DM_9fe8c30d-f5f6-4072-8bd5-19e0ac450915", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_DM_9fe8c30d-f5f6-4072-8bd5-19e0ac450915 です。", + "posted": { + "seq": 4, + "ts": "2026-09-28T15:09:23.983Z", + "from": "probe", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_DM_9fe8c30d-f5f6-4072-8bd5-19e0ac450915 です。", + "to": "bell", + "room_saved": true, + "delivery": { + "bell": { + "state": "pending", + "reason": "parent_receiver経由" + } + } + } + }, + { + "label": "MULTI", + "nonce": "PEERTABLE_PRODUCT_CLAUDE_MULTI_ec000d9a-a0d7-4808-8238-f14ba978ab25", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_MULTI_ec000d9a-a0d7-4808-8238-f14ba978ab25 です。", + "posted": { + "seq": 5, + "ts": "2026-09-28T15:09:27.793Z", + "from": "probe", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_MULTI_ec000d9a-a0d7-4808-8238-f14ba978ab25 です。", + "to_names": [ + "bell", + "rui" + ], + "room_saved": true, + "delivery": { + "bell": { + "state": "pending", + "reason": "parent_receiver経由" + }, + "rui": { + "state": "seat_unavailable", + "reason": "member_not_found" + } + } + } + } + ], + "replies": [ + { + "session": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "text": "初回\n\nparent_joinを呼び出します。" + }, + { + "session": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "text": "Peertableのroom観測受信待機中です。endpoint_id: `2fd31ec3-3395-4672-9937-7325ee232bae` で登録完了しました。観測データの到着を待ちます。" + }, + { + "session": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "text": "**観測データ受信報告**\n\n- **配送確認符号**: bccb991d-2a4b-467e-bec9-4ab161c56b70\n- **送信者**: peertable\n- **宛先**: bell\n- **seq**: probe:bccb991d-2a4b-467e-bec9-4ab161c56b70\n- **room**: parent-native-988245ab-9f7b-473e-8eac-6dd2e511c6e7\n- **配送ID**: 21400acc-30f2-4d75-bdc2-b2ddcbc88429\n- **digest**: ee7c08d21335288a1056596b0354db474c8f5b71fbaa78d0aee2d922b2e3f26f\n\n親会話受信継続中。" + }, + { + "session": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "text": "**観測データ受信報告**\n\n- **配送確認符号**: seq=2\n- **送信者**: system\n- **宛先**: bell\n- **seq**: 2\n- **room**: parent-native-988245ab-9f7b-473e-8eac-6dd2e511c6e7\n- **配送ID**: 7910a426-9f0b-470f-9245-37b38d0b1b19\n- **digest**: 4905c52ee8565f4b09b016791c69ea300b26fcb0a7c4abeef895e547ece54fc7\n- **本文**: bell が参加した\n\n親会話受信継続中。" + }, + { + "session": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "text": "**観測データ受信報告**\n\n- **配送確認符号**: e69e2cf953ea73d1238b193cbaf8e9e1cd2f44a427ff7c2509d15d1bbc22fafc\n- **送信者**: peertable\n- **宛先**: bell\n- **seq**: e69e2cf953ea73d1238b193cbaf8e9e1cd2f44a427ff7c2509d15d1bbc22fafc\n- **room**: parent-native-988245ab-9f7b-473e-8eac-6dd2e511c6e7\n- **配送ID**: a7a2fcf7-32a3-4450-aeb1-806f280df793\n- **digest**: a4cb05b1044323bd56504890c876c320490e6680d32fc208d7439e41aee9b6b3\n- **本文**: 親番犬を張り直した。room の親宛と工程件数を追従する。\n\n親会話受信継続中。" + }, + { + "session": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "text": "**観測データ受信報告**\n\n- **配送確認符号**: PEERTABLE_PRODUCT_CLAUDE_ALL_d8edb2a7-57a0-4f8a-a2a5-9313c4caef88\n- **送信者**: probe\n- **宛先**: all\n- **seq**: 3\n- **room**: parent-native-988245ab-9f7b-473e-8eac-6dd2e511c6e7\n- **配送ID**: 3a66882e-273e-4d71-be4c-9033c7463f4b\n- **digest**: 57232b740db0818479b39128bbcefeeead28845b34ae89a84dc8a5492dee9d19\n- **本文**: 日本語の観測「引用」😀\n\n親会話受信継続中。" + }, + { + "session": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "text": "**観測データ受信報告**\n\n- **配送確認符号**: PEERTABLE_PRODUCT_CLAUDE_DM_9fe8c30d-f5f6-4072-8bd5-19e0ac450915\n- **送信者**: probe\n- **宛先**: bell\n- **seq**: 4\n- **room**: parent-native-988245ab-9f7b-473e-8eac-6dd2e511c6e7\n- **配送ID**: ee080f9d-b978-4842-a55d-6e6b086bb833\n- **digest**: 863802ac3849bf098c887ffdf7b36fcd68609832700e466afc318a9260b6a133\n- **本文**: 日本語の観測「引用」😀\n\n親会話受信継続中。" + }, + { + "session": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "text": "**観測データ受信報告**\n\n- **配送確認符号**: PEERTABLE_PRODUCT_CLAUDE_MULTI_ec000d9a-a0d7-4808-8238-f14ba978ab25\n- **送信者**: probe\n- **宛先**: bell, rui\n- **seq**: 5\n- **room**: parent-native-988245ab-9f7b-473e-8eac-6dd2e511c6e7\n- **配送ID**: ac92bb7b-527e-409f-bb71-9303b0196273\n- **digest**: 37067b7a7f39b8c0b4f574dd05367a5f2600f83bbbca7f98138d5803984a4994\n- **本文**: 日本語の観測「引用」😀\n\n親会話受信継続中。" + } + ], + "delivery": [ + { + "schema": "peertable.delivery.v1", + "seq": 3, + "room_saved": true, + "delivery": { + "bell": { + "state": "delivered", + "reason": "claude_asyncRewake_output", + "at": "2026-09-28T15:09:15.059Z", + "receipt_revision": 1 + } + } + }, + { + "schema": "peertable.delivery.v1", + "seq": 4, + "room_saved": true, + "delivery": { + "bell": { + "state": "delivered", + "reason": "claude_asyncRewake_output", + "at": "2026-09-28T15:09:24.044Z", + "receipt_revision": 1 + } + } + }, + { + "schema": "peertable.delivery.v1", + "seq": 5, + "room_saved": true, + "delivery": { + "bell": { + "state": "delivered", + "reason": "claude_asyncRewake_output", + "at": "2026-09-28T15:09:27.887Z", + "receipt_revision": 1 + }, + "rui": { + "state": "seat_unavailable", + "reason": "member_not_found" + } + } + } + ], + "package_version": "0.8.62", + "cli_version": "2.1.283 (Claude Code)", + "node_version": "v24.20.0", + "harness": "claude", + "passed": true, + "runtime_digest": "1b24c1a855afb1b572f2d5a4d98515c327b43690f0bfeeec7dbca1687c1826d9", + "source_status": "未commit固定snapshotの実製品smoke。最終版とdigestが異なるため525件の受入manifestへ算入しない。", + "hook_events": [ + { + "type": "system", + "subtype": "hook_response", + "hook_event": "SessionStart", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "PreToolUse", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "{}\n", + "stderr": "", + "output": "{}\n" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "PostToolUse", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "PostToolUse", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 2, + "outcome": "error", + "stdout": "", + "stderr": "[Peertable room=parent-native-988245ab-9f7b-473e-8eac-6dd2e511c6e7 from=peertable to=\"bell\" seq=probe:bccb991d-2a4b-467e-bec9-4ab161c56b70]\n本文: 配送確認の符号は bccb991d-2a4b-467e-bec9-4ab161c56b70。\n[配送ID=21400acc-30f2-4d75-bdc2-b2ddcbc88429 digest=ee7c08d21335288a1056596b0354db474c8f5b71fbaa78d0aee2d922b2e3f26f]\n", + "output": "[Peertable room=parent-native-988245ab-9f7b-473e-8eac-6dd2e511c6e7 from=peertable to=\"bell\" seq=probe:bccb991d-2a4b-467e-bec9-4ab161c56b70]\n本文: 配送確認の符号は bccb991d-2a4b-467e-bec9-4ab161c56b70。\n[配送ID=21400acc-30f2-4d75-bdc2-b2ddcbc88429 digest=ee7c08d21335288a1056596b0354db474c8f5b71fbaa78d0aee2d922b2e3f26f]\n" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 2, + "outcome": "error", + "stdout": "", + "stderr": "[Peertable room=parent-native-988245ab-9f7b-473e-8eac-6dd2e511c6e7 from=system to=\"bell\" seq=2]\n本文: bell が参加した\n[配送ID=7910a426-9f0b-470f-9245-37b38d0b1b19 digest=4905c52ee8565f4b09b016791c69ea300b26fcb0a7c4abeef895e547ece54fc7]\n", + "output": "[Peertable room=parent-native-988245ab-9f7b-473e-8eac-6dd2e511c6e7 from=system to=\"bell\" seq=2]\n本文: bell が参加した\n[配送ID=7910a426-9f0b-470f-9245-37b38d0b1b19 digest=4905c52ee8565f4b09b016791c69ea300b26fcb0a7c4abeef895e547ece54fc7]\n" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 2, + "outcome": "error", + "stdout": "", + "stderr": "[Peertable room=parent-native-988245ab-9f7b-473e-8eac-6dd2e511c6e7 from=peertable to=\"bell\" seq=e69e2cf953ea73d1238b193cbaf8e9e1cd2f44a427ff7c2509d15d1bbc22fafc]\n本文: 親番犬を張り直した。room の親宛と工程件数を追従する。\n[配送ID=a7a2fcf7-32a3-4450-aeb1-806f280df793 digest=a4cb05b1044323bd56504890c876c320490e6680d32fc208d7439e41aee9b6b3]\n", + "output": "[Peertable room=parent-native-988245ab-9f7b-473e-8eac-6dd2e511c6e7 from=peertable to=\"bell\" seq=e69e2cf953ea73d1238b193cbaf8e9e1cd2f44a427ff7c2509d15d1bbc22fafc]\n本文: 親番犬を張り直した。room の親宛と工程件数を追従する。\n[配送ID=a7a2fcf7-32a3-4450-aeb1-806f280df793 digest=a4cb05b1044323bd56504890c876c320490e6680d32fc208d7439e41aee9b6b3]\n" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 2, + "outcome": "error", + "stdout": "", + "stderr": "[Peertable room=parent-native-988245ab-9f7b-473e-8eac-6dd2e511c6e7 from=probe to=\"all\" seq=3]\n本文: 日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_ALL_d8edb2a7-57a0-4f8a-a2a5-9313c4caef88 です。\n[配送ID=3a66882e-273e-4d71-be4c-9033c7463f4b digest=57232b740db0818479b39128bbcefeeead28845b34ae89a84dc8a5492dee9d19]\n", + "output": "[Peertable room=parent-native-988245ab-9f7b-473e-8eac-6dd2e511c6e7 from=probe to=\"all\" seq=3]\n本文: 日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_ALL_d8edb2a7-57a0-4f8a-a2a5-9313c4caef88 です。\n[配送ID=3a66882e-273e-4d71-be4c-9033c7463f4b digest=57232b740db0818479b39128bbcefeeead28845b34ae89a84dc8a5492dee9d19]\n" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 2, + "outcome": "error", + "stdout": "", + "stderr": "[Peertable room=parent-native-988245ab-9f7b-473e-8eac-6dd2e511c6e7 from=probe to=\"bell\" seq=4]\n本文: 日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_DM_9fe8c30d-f5f6-4072-8bd5-19e0ac450915 です。\n[配送ID=ee080f9d-b978-4842-a55d-6e6b086bb833 digest=863802ac3849bf098c887ffdf7b36fcd68609832700e466afc318a9260b6a133]\n", + "output": "[Peertable room=parent-native-988245ab-9f7b-473e-8eac-6dd2e511c6e7 from=probe to=\"bell\" seq=4]\n本文: 日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_DM_9fe8c30d-f5f6-4072-8bd5-19e0ac450915 です。\n[配送ID=ee080f9d-b978-4842-a55d-6e6b086bb833 digest=863802ac3849bf098c887ffdf7b36fcd68609832700e466afc318a9260b6a133]\n" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 2, + "outcome": "error", + "stdout": "", + "stderr": "[Peertable room=parent-native-988245ab-9f7b-473e-8eac-6dd2e511c6e7 from=probe to=[\"bell\",\"rui\"] seq=5]\n本文: 日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_MULTI_ec000d9a-a0d7-4808-8238-f14ba978ab25 です。\n[配送ID=ac92bb7b-527e-409f-bb71-9303b0196273 digest=37067b7a7f39b8c0b4f574dd05367a5f2600f83bbbca7f98138d5803984a4994]\n", + "output": "[Peertable room=parent-native-988245ab-9f7b-473e-8eac-6dd2e511c6e7 from=probe to=[\"bell\",\"rui\"] seq=5]\n本文: 日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_MULTI_ec000d9a-a0d7-4808-8238-f14ba978ab25 です。\n[配送ID=ac92bb7b-527e-409f-bb71-9303b0196273 digest=37067b7a7f39b8c0b4f574dd05367a5f2600f83bbbca7f98138d5803984a4994]\n" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "20ceda62-752a-4c1d-aee3-2780bdf07af6", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + } + ] +} diff --git a/rag/parent-delivery/product-snapshot-claude-win32.json b/rag/parent-delivery/product-snapshot-claude-win32.json new file mode 100644 index 00000000..8d2d7bbc --- /dev/null +++ b/rag/parent-delivery/product-snapshot-claude-win32.json @@ -0,0 +1,756 @@ +{ + "schema": "peertable.parent-product-draft-smoke.v1", + "platform": "win32", + "surface": "claude-cli-stream-json", + "room": "parent-native-328d92a1-395d-455c-99e6-4a633265badd", + "session": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "endpoint": "1bdf547c-d47c-40c6-8868-870c20ca8c9b", + "sent": [ + { + "label": "ALL", + "nonce": "PEERTABLE_PRODUCT_CLAUDE_ALL_9b182ef4-9374-4b1f-9562-7e27c2cd4ff4", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_ALL_9b182ef4-9374-4b1f-9562-7e27c2cd4ff4 です。", + "posted": { + "seq": 3, + "ts": "2026-09-28T15:13:10.003Z", + "from": "probe", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_ALL_9b182ef4-9374-4b1f-9562-7e27c2cd4ff4 です。", + "to": "all", + "room_saved": true, + "delivery": { + "bell": { + "state": "pending", + "reason": "parent_receiver経由" + } + } + } + }, + { + "label": "DM", + "nonce": "PEERTABLE_PRODUCT_CLAUDE_DM_d05b7b6d-143d-4dff-8225-9bd977d9a16a", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_DM_d05b7b6d-143d-4dff-8225-9bd977d9a16a です。", + "posted": { + "seq": 4, + "ts": "2026-09-28T15:15:04.505Z", + "from": "probe", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_DM_d05b7b6d-143d-4dff-8225-9bd977d9a16a です。", + "to": "bell", + "room_saved": true, + "delivery": { + "bell": { + "state": "pending", + "reason": "parent_receiver経由" + } + } + } + }, + { + "label": "MULTI", + "nonce": "PEERTABLE_PRODUCT_CLAUDE_MULTI_19f05ab6-9239-4f51-ac99-85d331d99a05", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_MULTI_19f05ab6-9239-4f51-ac99-85d331d99a05 です。", + "posted": { + "seq": 5, + "ts": "2026-09-28T15:15:27.074Z", + "from": "probe", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_MULTI_19f05ab6-9239-4f51-ac99-85d331d99a05 です。", + "to_names": [ + "bell", + "rui" + ], + "room_saved": true, + "delivery": { + "bell": { + "state": "pending", + "reason": "parent_receiver経由" + }, + "rui": { + "state": "seat_unavailable", + "reason": "member_not_found" + } + } + } + } + ], + "replies": [ + { + "session": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "text": "初回です。\n\nPeertable親会話に参加しました。\n- **endpoint_id**: `1bdf547c-d47c-40c6-8868-870c20ca8c9b`\n- **state**: receiving\n- **harness**: claude\n\n受信待機中です。観測データの到着を待ちます。" + }, + { + "session": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "text": "**Peertableルーム観測データ到着**\n\n配送確認の符号: `dafea4e4-e188-4783-9596-a29e2f2136f9`\n- 送信者: `peertable`\n- 宛先: `bell`\n- seq: `probe:dafea4e4-e188-4783-9596-a29e2f2136f9`\n- delivery_id: `2c3afced-70f8-4bcd-b7c0-e7730cebec5f`\n\n本文を全量回収します。" + }, + { + "session": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "text": "配送確認済み。本文ページは空。継続tokenなし。\n\n親会話受信を維持中です。" + }, + { + "session": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "text": "**Peertableルーム観測データ到着**\n\n配送確認の符号: `6411567a-7ab6-4e43-98cf-61d5d565dbc4`\n- 送信者: `system`\n- 宛先: `bell`\n- seq: `2`\n\n本文: `bell が参加した`\n\n親会話受信を維持中です。" + }, + { + "session": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "text": "**Peertableルーム観測データ到着**\n\n配送確認の符号: `8f23cb29-504a-407c-9868-c39f97476062`\n- 送信者: `peertable`\n- 宛先: `bell`\n- seq: `e69e2cf953ea73d1238b193cbaf8e9e1cd2f44a427ff7c2509d15d1bbc22fafc`\n\n本文: `親番犬を張り直した。room の親宛と工程件数を追従する。`\n\n親会話受信を維持中です。" + }, + { + "session": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "text": "**Peertableルーム観測データ到着**\n\n配送確認の符号: `0ac8a09e-88bf-44dd-8918-7a059be40850`\n- 送信者: `probe`\n- 宛先: `all`\n- seq: `3`\n\n本文:\n```\n日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_ALL_9b182ef4-9374-4b1f-9562-7e27c2cd4ff4 です。\n```\n\n親会話受信を維持中です。" + }, + { + "session": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "text": "**Peertableルーム観測データ到着**\n\n配送確認の符号: `297ff17c-f4e8-4a0a-922a-ec9fed923505`\n- 送信者: `probe`\n- 宛先: `bell`\n- seq: `4`\n\n本文:\n```\n日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_DM_d05b7b6d-143d-4dff-8225-9bd977d9a16a です。\n```\n\n親会話受信を維持中です。" + }, + { + "session": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "text": "**Peertableルーム観測データ到着**\n\n配送確認の符号: `2e300240-3afe-4e63-91f8-e5c53abfc402`\n- 送信者: `probe`\n- 宛先: `bell`, `rui`\n- seq: `5`\n\n本文:\n```\n日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_MULTI_19f05ab6-9239-4f51-ac99-85d331d99a05 です。\n```\n\n親会話受信を維持中です。" + } + ], + "delivery": [ + { + "schema": "peertable.delivery.v1", + "seq": 3, + "room_saved": true, + "delivery": { + "bell": { + "state": "delivered", + "reason": "claude_asyncRewake_output", + "at": "2026-09-28T15:14:43.203Z", + "receipt_revision": 1 + } + } + }, + { + "schema": "peertable.delivery.v1", + "seq": 4, + "room_saved": true, + "delivery": { + "bell": { + "state": "delivered", + "reason": "claude_asyncRewake_output", + "at": "2026-09-28T15:15:12.404Z", + "receipt_revision": 1 + } + } + }, + { + "schema": "peertable.delivery.v1", + "seq": 5, + "room_saved": true, + "delivery": { + "bell": { + "state": "delivered", + "reason": "claude_asyncRewake_output", + "at": "2026-09-28T15:15:35.140Z", + "receipt_revision": 1 + }, + "rui": { + "state": "seat_unavailable", + "reason": "member_not_found" + } + } + } + ], + "package_version": "0.8.62", + "cli_version": "2.1.283 (Claude Code)", + "node_version": "v24.19.0", + "harness": "claude", + "passed": true, + "runtime_digest": "1b24c1a855afb1b572f2d5a4d98515c327b43690f0bfeeec7dbca1687c1826d9", + "source_status": "未commit固定snapshotの実製品smoke。最終版とdigestが異なるため525件の受入manifestへ算入しない。", + "hook_events": [ + { + "type": "system", + "subtype": "hook_response", + "hook_event": "SessionStart", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "SessionStart", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "PreToolUse", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "PreToolUse", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "{}\n", + "stderr": "", + "output": "{}\n" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "PostToolUse", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "PostToolUse", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 2, + "outcome": "error", + "stdout": "", + "stderr": "[Peertable room=parent-native-328d92a1-395d-455c-99e6-4a633265badd from=peertable to=\"bell\" seq=probe:dafea4e4-e188-4783-9596-a29e2f2136f9]\n本文: 配送確認の符号は dafea4e4-e188-4783-9596-a29e2f2136f9。\n[配送ID=2c3afced-70f8-4bcd-b7c0-e7730cebec5f digest=8574c2fd3b775919ac90fa9dc845cda069efc2596a77205c3bf51cd59516592b]\n", + "output": "[Peertable room=parent-native-328d92a1-395d-455c-99e6-4a633265badd from=peertable to=\"bell\" seq=probe:dafea4e4-e188-4783-9596-a29e2f2136f9]\n本文: 配送確認の符号は dafea4e4-e188-4783-9596-a29e2f2136f9。\n[配送ID=2c3afced-70f8-4bcd-b7c0-e7730cebec5f digest=8574c2fd3b775919ac90fa9dc845cda069efc2596a77205c3bf51cd59516592b]\n" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "PreToolUse", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "PreToolUse", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "{}\n", + "stderr": "", + "output": "{}\n" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "PostToolUse", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 2, + "outcome": "error", + "stdout": "", + "stderr": "[Peertable room=parent-native-328d92a1-395d-455c-99e6-4a633265badd from=system to=\"bell\" seq=2]\n本文: bell が参加した\n[配送ID=6411567a-7ab6-4e43-98cf-61d5d565dbc4 digest=c0a9609259560d3613832072d62f8c4c2a10b5182eeb853cfef7a8ae75bca28e]\n", + "output": "[Peertable room=parent-native-328d92a1-395d-455c-99e6-4a633265badd from=system to=\"bell\" seq=2]\n本文: bell が参加した\n[配送ID=6411567a-7ab6-4e43-98cf-61d5d565dbc4 digest=c0a9609259560d3613832072d62f8c4c2a10b5182eeb853cfef7a8ae75bca28e]\n" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 2, + "outcome": "error", + "stdout": "", + "stderr": "[Peertable room=parent-native-328d92a1-395d-455c-99e6-4a633265badd from=peertable to=\"bell\" seq=e69e2cf953ea73d1238b193cbaf8e9e1cd2f44a427ff7c2509d15d1bbc22fafc]\n本文: 親番犬を張り直した。room の親宛と工程件数を追従する。\n[配送ID=8f23cb29-504a-407c-9868-c39f97476062 digest=4e794bd28d32e7032e2d9bae8f6cede7a7db0991c613569133e48b299ca4f294]\n", + "output": "[Peertable room=parent-native-328d92a1-395d-455c-99e6-4a633265badd from=peertable to=\"bell\" seq=e69e2cf953ea73d1238b193cbaf8e9e1cd2f44a427ff7c2509d15d1bbc22fafc]\n本文: 親番犬を張り直した。room の親宛と工程件数を追従する。\n[配送ID=8f23cb29-504a-407c-9868-c39f97476062 digest=4e794bd28d32e7032e2d9bae8f6cede7a7db0991c613569133e48b299ca4f294]\n" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 2, + "outcome": "error", + "stdout": "", + "stderr": "[Peertable room=parent-native-328d92a1-395d-455c-99e6-4a633265badd from=probe to=\"all\" seq=3]\n本文: 日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_ALL_9b182ef4-9374-4b1f-9562-7e27c2cd4ff4 です。\n[配送ID=0ac8a09e-88bf-44dd-8918-7a059be40850 digest=285ae988db5447f60a6513c46d0bacd0612799c4269c5c6c8673f678d19bcc02]\n", + "output": "[Peertable room=parent-native-328d92a1-395d-455c-99e6-4a633265badd from=probe to=\"all\" seq=3]\n本文: 日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_ALL_9b182ef4-9374-4b1f-9562-7e27c2cd4ff4 です。\n[配送ID=0ac8a09e-88bf-44dd-8918-7a059be40850 digest=285ae988db5447f60a6513c46d0bacd0612799c4269c5c6c8673f678d19bcc02]\n" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "[caveat:hook] Jev判定に失敗: The operation was aborted due to timeout\n", + "output": "[caveat:hook] Jev判定に失敗: The operation was aborted due to timeout\n" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 2, + "outcome": "error", + "stdout": "", + "stderr": "[Peertable room=parent-native-328d92a1-395d-455c-99e6-4a633265badd from=probe to=\"bell\" seq=4]\n本文: 日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_DM_d05b7b6d-143d-4dff-8225-9bd977d9a16a です。\n[配送ID=297ff17c-f4e8-4a0a-922a-ec9fed923505 digest=b2ccc57a814ab28b0001294e0c985cc409ec2cd1e377ac1fb5aa3719fb161405]\n", + "output": "[Peertable room=parent-native-328d92a1-395d-455c-99e6-4a633265badd from=probe to=\"bell\" seq=4]\n本文: 日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_DM_d05b7b6d-143d-4dff-8225-9bd977d9a16a です。\n[配送ID=297ff17c-f4e8-4a0a-922a-ec9fed923505 digest=b2ccc57a814ab28b0001294e0c985cc409ec2cd1e377ac1fb5aa3719fb161405]\n" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "[caveat:hook] Jev判定に失敗: The operation was aborted due to timeout\n", + "output": "[caveat:hook] Jev判定に失敗: The operation was aborted due to timeout\n" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 2, + "outcome": "error", + "stdout": "", + "stderr": "[Peertable room=parent-native-328d92a1-395d-455c-99e6-4a633265badd from=probe to=[\"bell\",\"rui\"] seq=5]\n本文: 日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_MULTI_19f05ab6-9239-4f51-ac99-85d331d99a05 です。\n[配送ID=2e300240-3afe-4e63-91f8-e5c53abfc402 digest=15c9d734fc9f9eaa1dea55e6d6c8273b4f7d6a165025c7f9124b6d66f2a5f1f7]\n", + "output": "[Peertable room=parent-native-328d92a1-395d-455c-99e6-4a633265badd from=probe to=[\"bell\",\"rui\"] seq=5]\n本文: 日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CLAUDE_MULTI_19f05ab6-9239-4f51-ac99-85d331d99a05 です。\n[配送ID=2e300240-3afe-4e63-91f8-e5c53abfc402 digest=15c9d734fc9f9eaa1dea55e6d6c8273b4f7d6a165025c7f9124b6d66f2a5f1f7]\n" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "UserPromptSubmit", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + }, + { + "type": "system", + "subtype": "hook_response", + "hook_event": "Stop", + "session_id": "8e18be0b-9d70-4eac-9f54-c8b51bbaf4ad", + "exit_code": 0, + "outcome": "success", + "stdout": "", + "stderr": "", + "output": "" + } + ] +} diff --git a/rag/parent-delivery/product-snapshot-codex-darwin.json b/rag/parent-delivery/product-snapshot-codex-darwin.json new file mode 100644 index 00000000..878cd620 --- /dev/null +++ b/rag/parent-delivery/product-snapshot-codex-darwin.json @@ -0,0 +1,291 @@ +{ + "schema": "peertable.parent-product-draft-smoke.v1", + "platform": "darwin", + "surface": "codex-cli-app-server", + "room": "parent-codex-e856357e-fb6a-4fee-89ae-b29af2d3187d", + "thread": "01a0e88e-8362-7911-9010-c1b7121b844e", + "endpoint": "f38a6fc1-ac87-48cd-b39b-a63ec62b39ac", + "sent": [ + { + "label": "ALL", + "nonce": "PEERTABLE_PRODUCT_CODEX_ALL_acaca205-c3f0-49ae-abd8-97888f43674a", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CODEX_ALL_acaca205-c3f0-49ae-abd8-97888f43674a です。", + "posted": { + "seq": 3, + "ts": "2026-09-28T15:07:44.451Z", + "from": "probe", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CODEX_ALL_acaca205-c3f0-49ae-abd8-97888f43674a です。", + "to": "all", + "room_saved": true, + "delivery": { + "bell": { + "state": "pending", + "reason": "parent_receiver経由" + } + } + } + }, + { + "label": "DM", + "nonce": "PEERTABLE_PRODUCT_CODEX_DM_68dedc41-1941-4f5e-accc-32f480fed844", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CODEX_DM_68dedc41-1941-4f5e-accc-32f480fed844 です。", + "posted": { + "seq": 4, + "ts": "2026-09-28T15:08:39.393Z", + "from": "probe", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CODEX_DM_68dedc41-1941-4f5e-accc-32f480fed844 です。", + "to": "bell", + "room_saved": true, + "delivery": { + "bell": { + "state": "pending", + "reason": "parent_receiver経由" + } + } + } + }, + { + "label": "MULTI", + "nonce": "PEERTABLE_PRODUCT_CODEX_MULTI_3801cb1f-1256-42eb-b0ec-084d065f2280", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CODEX_MULTI_3801cb1f-1256-42eb-b0ec-084d065f2280 です。", + "posted": { + "seq": 5, + "ts": "2026-09-28T15:08:42.943Z", + "from": "probe", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CODEX_MULTI_3801cb1f-1256-42eb-b0ec-084d065f2280 です。", + "to_names": [ + "bell", + "rui" + ], + "room_saved": true, + "delivery": { + "bell": { + "state": "pending", + "reason": "parent_receiver経由" + }, + "rui": { + "state": "seat_unavailable", + "reason": "member_not_found" + } + } + } + } + ], + "replies": [ + { + "thread": "01a0e88e-8362-7911-9010-c1b7121b844e", + "turn": "01a0e88e-841d-7273-aefe-5e3961fbc1e4", + "text": "peertable_parent.parent_joinを指定のprojectとnameで一度呼び出します。結果を確認したら、同じ会話に届くroom観測データの受信を維持します。" + }, + { + "thread": "01a0e88e-8362-7911-9010-c1b7121b844e", + "turn": "01a0e88e-841d-7273-aefe-5e3961fbc1e4", + "text": "初回。受信登録は`receiving`です。届いたroom観測データは次のとおりです。\n\n- 配送確認符号: `498a130d-c687-404d-a82c-6920aa49b1e4`\n- 送信者: `peertable`\n- 宛先: `bell`\n- seq: `probe:498a130d-c687-404d-a82c-6920aa49b1e4`" + }, + { + "thread": "01a0e88e-8362-7911-9010-c1b7121b844e", + "turn": "01a0e88e-841d-7273-aefe-5e3961fbc1e4", + "text": "room観測データを受信しました。\n\n- 配送確認符号: 本文に明示なし\n- 送信者: `system`\n- 宛先: `bell`\n- seq: `2`\n- 配送ID: `b393f75a-a7a1-4bf2-916b-2fd8248d90a4`" + }, + { + "thread": "01a0e88e-8362-7911-9010-c1b7121b844e", + "turn": "01a0e88e-841d-7273-aefe-5e3961fbc1e4", + "text": "room観測データを受信しました。\n\n- 配送確認符号: 本文に明示なし\n- 送信者: `peertable`\n- 宛先: `bell`\n- seq: `e69e2cf953ea73d1238b193cbaf8e9e1cd2f44a427ff7c2509d15d1bbc22fafc`\n- 配送ID: `c22a1294-e3cc-4e95-9889-ad0b7f9e200c`" + }, + { + "thread": "01a0e88e-8362-7911-9010-c1b7121b844e", + "turn": "01a0e88e-841d-7273-aefe-5e3961fbc1e4", + "text": "room観測データを受信しました。\n\n- 配送確認符号: `PEERTABLE_PRODUCT_CODEX_ALL_acaca205-c3f0-49ae-abd8-97888f43674a`\n- 送信者: `probe`\n- 宛先: `all`\n- seq: `3`" + }, + { + "thread": "01a0e88e-8362-7911-9010-c1b7121b844e", + "turn": "01a0e88e-841d-7273-aefe-5e3961fbc1e4", + "text": "room観測データを受信しました。\n\n- 配送確認符号: `PEERTABLE_PRODUCT_CODEX_DM_68dedc41-1941-4f5e-accc-32f480fed844`\n- 送信者: `probe`\n- 宛先: `bell`\n- seq: `4`" + }, + { + "thread": "01a0e88e-8362-7911-9010-c1b7121b844e", + "turn": "01a0e88e-841d-7273-aefe-5e3961fbc1e4", + "text": "room観測データを受信しました。\n\n- 配送確認符号: `PEERTABLE_PRODUCT_CODEX_MULTI_3801cb1f-1256-42eb-b0ec-084d065f2280`\n- 送信者: `probe`\n- 宛先: `[\"bell\",\"rui\"]`\n- seq: `5`" + } + ], + "receipts": [ + { + "schema": "peertable.delivery.v1", + "seq": 3, + "room_saved": true, + "delivery": { + "bell": { + "state": "delivered", + "reason": "codex_hook_output_complete", + "at": "2026-09-28T15:08:33.961Z", + "queued_submission_id": "01a0e88f-225a-7911-9322-2d62e0544da7", + "accepted_at": "2026-09-28T15:08:09.691Z", + "receipt_revision": 2 + } + } + }, + { + "schema": "peertable.delivery.v1", + "seq": 4, + "room_saved": true, + "delivery": { + "bell": { + "state": "delivered", + "reason": "codex_hook_output_complete", + "at": "2026-09-28T15:08:39.627Z", + "queued_submission_id": "01a0e88f-96dd-7e63-9db9-e83cee19e33d", + "accepted_at": "2026-09-28T15:08:39.524Z", + "receipt_revision": 2 + } + } + }, + { + "schema": "peertable.delivery.v1", + "seq": 5, + "room_saved": true, + "delivery": { + "bell": { + "state": "delivered", + "reason": "codex_hook_output_complete", + "at": "2026-09-28T15:08:43.131Z", + "queued_submission_id": "01a0e88f-a49e-7482-a406-fdf09516ca6b", + "accepted_at": "2026-09-28T15:08:43.039Z", + "receipt_revision": 2 + }, + "rui": { + "state": "seat_unavailable", + "reason": "member_not_found" + } + } + } + ], + "harness": "codex", + "passed": true, + "runtime_digest": "27c5c1980b1d6ce2165889a84e57aeee228120f126d56fbb0e439bcc91f1ef47", + "source_status": "未commit固定snapshotの実製品smoke。最終版とdigestが異なるため525件の受入manifestへ算入しない。", + "local_records": [ + { + "delivery_id": "9cc6ba05-146e-46e3-aac4-8f34ce0779fa", + "state": "submitted", + "event": { + "schema": "peertable.parent-watch-event.v1", + "type": "parent_room_update", + "parent": "bell", + "seq": 3, + "from": "probe", + "to": "all", + "to_names": null, + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CODEX_ALL_acaca205-c3f0-49ae-abd8-97888f43674a です。", + "message": { + "seq": 3, + "ts": "2026-09-28T15:07:44.451Z", + "from": "probe", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CODEX_ALL_acaca205-c3f0-49ae-abd8-97888f43674a です。", + "to": "all" + }, + "room": "parent-codex-e856357e-fb6a-4fee-89ae-b29af2d3187d", + "event_id": "room:3" + }, + "digest": "eaf12ddf1009ebe5cb0e1da9dd70c863982a0ec38291ba5fe4d73c00259353ec", + "queued_submission_id": "01a0e88f-225a-7911-9322-2d62e0544da7", + "accepted_at": "2026-09-28T15:08:09.691Z", + "hook_state": "output_complete", + "receipt": { + "seq": 3, + "recipient": "bell", + "result": "delivered", + "reason": "codex_hook_output_complete", + "route": "parent_receiver", + "endpoint_id": "f38a6fc1-ac87-48cd-b39b-a63ec62b39ac", + "receipt_revision": 2, + "queued_submission_id": "01a0e88f-225a-7911-9322-2d62e0544da7", + "accepted_at": "2026-09-28T15:08:09.691Z", + "pending": false + } + }, + { + "delivery_id": "151db00f-1bc0-480e-9d8a-843e258450ca", + "state": "submitted", + "event": { + "schema": "peertable.parent-watch-event.v1", + "type": "parent_dm", + "parent": "bell", + "seq": 4, + "from": "probe", + "to": "bell", + "to_names": null, + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CODEX_DM_68dedc41-1941-4f5e-accc-32f480fed844 です。", + "message": { + "seq": 4, + "ts": "2026-09-28T15:08:39.393Z", + "from": "probe", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CODEX_DM_68dedc41-1941-4f5e-accc-32f480fed844 です。", + "to": "bell" + }, + "room": "parent-codex-e856357e-fb6a-4fee-89ae-b29af2d3187d", + "event_id": "room:4" + }, + "digest": "6b06094db9af54134dc9bf8bc436573010c7c76bc50c2a8eda293c9780b3e407", + "queued_submission_id": "01a0e88f-96dd-7e63-9db9-e83cee19e33d", + "accepted_at": "2026-09-28T15:08:39.524Z", + "hook_state": "output_complete", + "receipt": { + "seq": 4, + "recipient": "bell", + "result": "delivered", + "reason": "codex_hook_output_complete", + "route": "parent_receiver", + "endpoint_id": "f38a6fc1-ac87-48cd-b39b-a63ec62b39ac", + "receipt_revision": 2, + "queued_submission_id": "01a0e88f-96dd-7e63-9db9-e83cee19e33d", + "accepted_at": "2026-09-28T15:08:39.524Z", + "pending": false + } + }, + { + "delivery_id": "b2ecb795-2173-4e3d-ab5d-cd64b5c81271", + "state": "submitted", + "event": { + "schema": "peertable.parent-watch-event.v1", + "type": "parent_dm", + "parent": "bell", + "seq": 5, + "from": "probe", + "to": null, + "to_names": [ + "bell", + "rui" + ], + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CODEX_MULTI_3801cb1f-1256-42eb-b0ec-084d065f2280 です。", + "message": { + "seq": 5, + "ts": "2026-09-28T15:08:42.943Z", + "from": "probe", + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CODEX_MULTI_3801cb1f-1256-42eb-b0ec-084d065f2280 です。", + "to_names": [ + "bell", + "rui" + ] + }, + "room": "parent-codex-e856357e-fb6a-4fee-89ae-b29af2d3187d", + "event_id": "room:5" + }, + "digest": "1cad3ad53c0506f6a2a86d4e9359c7b26677041a3a2c2e25a39354b1792fcb4f", + "queued_submission_id": "01a0e88f-a49e-7482-a406-fdf09516ca6b", + "accepted_at": "2026-09-28T15:08:43.039Z", + "hook_state": "output_complete", + "receipt": { + "seq": 5, + "recipient": "bell", + "result": "delivered", + "reason": "codex_hook_output_complete", + "route": "parent_receiver", + "endpoint_id": "f38a6fc1-ac87-48cd-b39b-a63ec62b39ac", + "receipt_revision": 2, + "queued_submission_id": "01a0e88f-a49e-7482-a406-fdf09516ca6b", + "accepted_at": "2026-09-28T15:08:43.039Z", + "pending": false + } + } + ] +} diff --git a/rag/parent-delivery/receivers.md b/rag/parent-delivery/receivers.md index 4ca4d3c5..a9427326 100644 --- a/rag/parent-delivery/receivers.md +++ b/rag/parent-delivery/receivers.md @@ -29,3 +29,15 @@ 公式仕様の記述、Aitermでの試験、Peertableの新機構の成立は別の証拠である。Aitermの成功をPeertableの12組合せの成功へ置き換えない。 第1工程のmacOS実測は[caller相関と背景完了の記録](phase1-macos-caller-background.md)へ保存した。Cursor/Grokの事前hook入力書換え・別会話拒否・通常TUIの2件の背景受信を観測済み。Codex queue+同期hookと製品版の12組合せは未合格。 + +## 製品実装チェックポイント + +第1工程後、Peertable所有の共通spool、3親MCP tool、4receiver、hook/connectと所有設定、OS adapter、runtime移行とsource監視、setup/resume/doctor/teardown、通常席除外、全件all配送を実装した。Aiterm内部moduleをruntime importしない。原文・continuation・PID開始identity・hardlink claim・unknownと不変queue受付証拠を保持する。 + +親の独立probeではClaude Stop継続とlease更新、Codex BUSY/STOP/IDLEが3 OSで成立した。sourceは[Claude Stop](../../experiments/parent-claude-stop-native/README.md)、[Claude lease](../../experiments/parent-claude-lease-native/README.md)、[Codex queue](../../experiments/parent-codex-queue-native/README.md)へ保存した。各`phase1-*.json`はprivate HOMEや認証設定を除いた投影である。Stop macOSとlease Linux/Windowsの投影はnative hook全文の原本回収が未完了であり、`native_hook_evidence_complete:false`を保持する。親の実測成立と、公開投影の完全性を同じ判定にしない。 + +親の実製品snapshotではClaude 3 OSとCodex macOSのALL/DM/MULTIが同じ会話へ届き、room deliveredまで観測された。必要fieldだけを`product-snapshot-*.json`へ保存した。各runtime_digestは試験時の固定snapshotであり、最新実装と異なる。525件の最終受入へ算入しない。Cursor/Grokの6 CLIは親の別QAが継続中。GUI Cursorはactivation失敗でprompt未送信のため配送合否を出さない。Codex Desktop/IDE等のlive受入も残る。 + +親のCodex idle長文試験でroomのUTF-8断片decodeによる原文破損が再現された。`readBody`をNodeのstream decoderへ修理し、[HTTP UTF-8再現](../../experiments/http-utf8-body-repro.mjs)で40,097 UTF-16文字の日本語・emojiの途中を5箇所で分割する。POST応答、GET messages、保存logの完全一致をfocused検証する。修理前のnative idle長文試験は失敗証拠として親が保管し、成功へ変更しない。 + +公開前gateは`npm run verify:parent-delivery`で[実機manifest](product-acceptance.json)を検査する。3 OS・7実行面・25scenarioの525件を要求し、欠落/skip/fixture・証拠file欠落・source digest/version不一致を拒否する。現manifestは空で、公開gateは未達。gateは会話/原文/receipt等の共通証拠を機械照合し、scenario固有の実観測の十分性は親の受入監査でも確認する。実装チェックポイントと全製品受入完了を分ける。 From 62ecbaaf80aaca728ecde6e43d5ee761ac64ec7d Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:41:21 +0900 Subject: [PATCH 04/72] =?UTF-8?q?=E7=B5=B1=E5=90=88=E9=85=8D=E9=80=81fixtu?= =?UTF-8?q?re=E3=81=AE=E8=A6=AA=E9=80=9A=E7=9F=A5=E5=85=88=E3=82=92room?= =?UTF-8?q?=E5=8F=B0=E5=B8=B3=E3=81=B8=E5=90=88=E3=82=8F=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 正式parent_receiver memberを追加し、旧親名envには無関係な値を設定する。 初回の失敗POSTと再起動後の再試行が台帳の親だけを選ぶことを検証する。 experiments/aiterm-unified-delivery-repro.mjsのfocused再現: 成功。 製品コードは変更しない。 --- experiments/aiterm-unified-delivery-repro.mjs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/experiments/aiterm-unified-delivery-repro.mjs b/experiments/aiterm-unified-delivery-repro.mjs index 583ed92a..b1d1d091 100644 --- a/experiments/aiterm-unified-delivery-repro.mjs +++ b/experiments/aiterm-unified-delivery-repro.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict' import { spawn } from 'node:child_process' import { once } from 'node:events' +import { randomUUID } from 'node:crypto' import { mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises' import { createServer } from 'node:http' import { tmpdir } from 'node:os' @@ -19,10 +20,12 @@ const specPath = join(root, 'spec.json') const callsPath = join(root, 'calls.jsonl') const statePath = join(project, '.team/wakeup-bridge-delivery.json') const seats = ['claude', 'codex', 'grok', 'cursor'] -const members = seats.map(name => ({ name, harness: name, aiterm_session_id: `session-${name}`, read_seq: 0 })) +const actualParent = { name: 'parent', harness: 'codex', delivery: { kind: 'parent_receiver', harness: 'codex', endpoint_id: randomUUID() } } +const members = [...seats.map(name => ({ name, harness: name, aiterm_session_id: `session-${name}`, read_seq: 0 })), actualParent] const messages = [] const receipts = new Map() const notifications = [] +const noticeAttempts = [] const streams = new Set() let failNoticeOnce = true let child @@ -58,6 +61,7 @@ const server = createServer(async (request, response) => { } else if (request.method === 'POST' && path.pathname.endsWith('/messages')) { const chunks = []; for await (const chunk of request) chunks.push(chunk) const data = JSON.parse(Buffer.concat(chunks).toString()) + if (data.from === 'wakeup') noticeAttempts.push(data) if (data.from === 'wakeup' && failNoticeOnce) { failNoticeOnce = false; send(500, {}); return } notifications.push(data) send(200, { seq: 999 }) @@ -76,7 +80,8 @@ Object.assign(fixtureEnv, { PEERTABLE_TEST_AITERM_SPEC: specPath, PEERTABLE_TEST_AITERM_CALLS: callsPath, PEERTABLE_TEST_DELIVERY_STATE: statePath, - PEERTABLE_PARENT_NAME: 'parent', + // 旧envを無関係な名前にし、room台帳の親だけを通知先へ選ぶことを検証する。 + PEERTABLE_PARENT_NAME: 'env-not-the-room-parent', }) const start = () => { child = spawn(process.execPath, [join(repo, 'skill/scripts/wakeup-bridge.mjs'), project], { @@ -122,13 +127,15 @@ try { await waitFor(() => receipts.get(`${heldSeq}:cursor`)?.result === 'failed', 'held receipt') assert.equal(receipts.get(`${heldSeq}:cursor`).reason, 'STEER_NOT_QUEUED') assert.equal(failNoticeOnce, false, '親への初回POSTが失敗した') + assert.equal(noticeAttempts[0].to, actualParent.name, '初回POSTは旧envでなく台帳の親を宛先にする') const before = (await calls()).length assert.equal(before, 6) await stop() await setSpec({ state: 'idle' }) start() await waitFor(() => notifications.some(item => item.body.includes(`seq=${heldSeq}`)), '再起動後の親通知再試行') - assert.ok(notifications.some(item => item.body.includes(`seq=${heldSeq}`) && item.body.includes('理由=STEER_NOT_QUEUED'))) + assert.ok(notifications.some(item => item.body.includes(`seq=${heldSeq}`) && item.body.includes('理由=STEER_NOT_QUEUED') && item.to === actualParent.name), '再起動後の再試行も台帳の親へ通知する') + assert.ok(noticeAttempts.every(item => item.to === actualParent.name), '失敗したPOSTも含め通知先は台帳の親だけ') const state = JSON.parse(await readFile(join(project, '.team/wakeup-bridge-delivery.json'))) assert.equal(state.held[`${heldSeq}:cursor`], 'STEER_NOT_QUEUED') assert.ok(state.notified.includes(`${heldSeq}:cursor`)) From ccf24edfab47ae42b0f9b914b4e76f88143707cf Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:52:20 +0900 Subject: [PATCH 05/72] =?UTF-8?q?=E8=A6=AA=E9=85=8D=E9=80=81=E3=81=AE?= =?UTF-8?q?=E5=8F=97=E5=85=A5=E8=A8=BC=E8=B7=A1=E3=82=92=E6=A4=9C=E8=A8=BC?= =?UTF-8?q?=E6=B8=88=E3=81=BFsource=E3=81=AE=E5=8C=85=E5=90=AB=E3=81=A8?= =?UTF-8?q?=E7=85=A7=E5=90=88=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plan_parent-native-delivery.md | 4 +++- scripts/parent-delivery-acceptance.mjs | 20 ++++++++++++++--- scripts/parent-delivery-acceptance.test.mjs | 24 ++++++++++++++++++++- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/docs/plan_parent-native-delivery.md b/docs/plan_parent-native-delivery.md index ba604235..190ba3a5 100644 --- a/docs/plan_parent-native-delivery.md +++ b/docs/plan_parent-native-delivery.md @@ -338,7 +338,7 @@ fixtureが必要とする設定は公式の一時設定指定と使い捨てproj | 束縛・probeの期限切れ | 原因code付きfailedとなり、永久pendingや未確認verifiedを残さない | | 複数hook・受信維持の同時開始 | 受信slotは1つ。生きた背景待機がある場合は新taskを増やさない | | 同版更新・新版移行・teardown | 登録重複なし、履歴保持、受信停止、親processは継続 | -| 公開packageからの導入 | checkout依存や開発用pathなしで同じ受入が成立 | +| 配布packageからの導入 | 公開前は`npm pack`の配布物を導入し、checkout依存や開発用pathなしで同じ受入が成立。公開後はregistry版を導入して再確認 | 上表のscenarioはすべて全12行へ展開する。各行の観測はそのreceiverの実境界で行い、Codex固有のqueue削除はCodex、hookと背景受信の競合はCursor、他receiverは自身のclaimと出力の競合を記録する。生成器が適用対象を推測しないよう、構造化目録には各scenarioの共通期待とadapter別の具体的な観測手順を固定する。CodexのDesktop/IDE/CLI、CursorのDesktop/CLIは実際の利用面を別に記録する。Cursorの選択modelがAutoやGrokでも同じ経路を確認し、Claude系modelの利用上限をCursor harness全体の未対応と混同しない。 @@ -348,6 +348,8 @@ fixtureは実process境界・競合・障害を速く再現する。実機は親 関連focused testを閉じ、最終の製品CIを3 OSで1回通す。受入目録の完全性と、第4工程の全12行・全必須実行面の実機合格をtag作成前のrelease gateに含める。`npm pack --dry-run`で新しい親client module、hook entry、背景受信entry、スキルがすべて配布されることを確認する。現在のfilesはroomのファイルを限定列挙しているため、新moduleの追加と同じ変更で更新する。version bump、main着地、tagによるTrusted Publishing、global install、12組合せの公開後smokeまで行う。公開後smokeは公開packageの導入結果の再確認であり、実機成立を初めて確かめる工程にしない。room serverの変更を含むため、本番反映・rollbackは[deploy正本](../deploy/README.md)に従う。 +証跡の`source_commit`は実測したコードのcommitを指す。証跡保存のcommitはその子孫になるため、gateは検証対象commitのrelease候補への包含、room・skill・配布設定の変更がないこと、現在のruntime digest、package versionをすべて照合する。コードやversionが変わった候補へ、古い実測証拠を流用しない。公開前のpackage試験は実際にpackした配布物を使い、registryからの導入は公開後smokeで再確認する。 + 既存のAiterm席への通常配送も最終関連試験に含める。親の改善で、通常メンバーの`pty_send`、配送保留、Cursorのroom MCP設定、読了cursor、解散処理を退行させない。 ## 10. 実装者への引渡し diff --git a/scripts/parent-delivery-acceptance.mjs b/scripts/parent-delivery-acceptance.mjs index 8a717e1a..553be232 100644 --- a/scripts/parent-delivery-acceptance.mjs +++ b/scripts/parent-delivery-acceptance.mjs @@ -17,7 +17,7 @@ export const scenarios = { receipt_retry: '受付後のreceipt失敗はreceiptだけ復旧', session_change: 'clear/終了/別会話/resumeで旧本文誤流入なし', parallel_rooms: '複数room/親会話の所有分離', unavailable_hook: '無効/未承認/実行file欠落は原因付き失敗', foreign_ownership: '利用者/Aitermのqueue・hook・承認・順序保持', compatibility_hooks: '互換hookの二重相関なし', background_end: 'task cancel/timeout/終了は成功へ丸めない', lease: '有限lease更新とendpoint/cursor/本文保持', binding_deadline: '束縛/probe期限のtyped failure', - slot_race: '複数hook開始でも1slot', lifecycle: '同版/新版更新・teardownで親processと履歴保持', package: '公開packageから同じ経路が成立', + slot_race: '複数hook開始でも1slot', lifecycle: '同版/新版更新・teardownで親processと履歴保持', package: 'npm packの配布物から同じ経路が成立。公開後もregistry版で再確認', } export const adapterObservations = { claude: ['実session/tool_use_idとMCP claudecode/toolUseId', 'PostToolUse/Stop asyncRewakeのnative hook_response exit2と後続assistant', 'PID+開始identityのsession1slot'], @@ -30,6 +30,18 @@ export function expectedCases() { id: `${os}/${harness}/${surface}/${scenario}`, os, harness, surface, scenario, expected, observations: adapterObservations[harness], }))))) } +export function testedSourceCommit(records, { repo = root, sourceCommit } = {}) { + const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim() + const tested = sourceCommit ?? records[0]?.source_commit ?? head + if (!/^[0-9a-f]{40}$/u.test(tested)) throw Object.assign(new Error('検証対象のcommitが不正です'), { code: 'PARENT_ACCEPTANCE_SOURCE_INVALID' }) + // 証拠を保存するcommit自身のSHAは証拠に書けない。検証済みcommitの包含と現在のruntime digestを別々に照合する。 + try { execFileSync('git', ['merge-base', '--is-ancestor', tested, head], { cwd: repo, stdio: ['ignore', 'pipe', 'pipe'] }) } + catch (error) { throw Object.assign(new Error('検証対象のcommitがrelease候補に含まれていません', { cause: error }), { code: 'PARENT_ACCEPTANCE_SOURCE_NOT_INCLUDED' }) } + // runtime digestが対象としないroom serverや配布設定も、検証後に変わっていないことを照合する。 + const changed = execFileSync('git', ['diff', '--name-only', tested, '--', 'room', 'skill', 'package.json', 'package-lock.json'], { cwd: repo, encoding: 'utf8' }).trim().split('\n').filter(file => file && !file.endsWith('.test.mjs')) + if (changed.length) throw Object.assign(new Error(`検証後に製品sourceが変わっています: ${changed.join(', ')}`), { code: 'PARENT_ACCEPTANCE_SOURCE_CHANGED' }) + return tested +} export function auditAcceptance(records, { sourceCommit, sourceDigest, packageVersion, readEvidence = file => JSON.parse(readFileSync(resolve(root, file), 'utf8')) } = {}) { const byId = new Map(), errors = [] for (const record of records) { if (byId.has(record.id)) errors.push({ id: record.id, code: 'PARENT_ACCEPTANCE_DUPLICATE' }); byId.set(record.id, record) } @@ -60,6 +72,8 @@ export function auditAcceptance(records, { sourceCommit, sourceDigest, packageVe } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const [file, commit] = process.argv.slice(2) - const records = JSON.parse(readFileSync(file, 'utf8')).records - const result = auditAcceptance(records, { sourceCommit: commit ?? execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(), sourceDigest: runtimeDigest(), packageVersion: JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')).version }); console.log(JSON.stringify(result)); if (result.status !== 'passed') process.exitCode = 1 + const manifest = JSON.parse(readFileSync(file, 'utf8')), records = manifest.records + try { + const result = auditAcceptance(records, { sourceCommit: testedSourceCommit(records, { sourceCommit: commit ?? manifest.source_commit }), sourceDigest: runtimeDigest(), packageVersion: JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')).version }); console.log(JSON.stringify(result)); if (result.status !== 'passed') process.exitCode = 1 + } catch (error) { console.log(JSON.stringify({ schema: 'peertable.parent-acceptance-audit.v1', status: 'failed', expected: expectedCases().length, errors: [{ code: error.code ?? 'PARENT_ACCEPTANCE_SOURCE_FAILED', detail: error.message }] })); process.exitCode = 1 } } diff --git a/scripts/parent-delivery-acceptance.test.mjs b/scripts/parent-delivery-acceptance.test.mjs index af46a3e8..267393a8 100644 --- a/scripts/parent-delivery-acceptance.test.mjs +++ b/scripts/parent-delivery-acceptance.test.mjs @@ -1,6 +1,10 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { expectedCases, auditAcceptance } from './parent-delivery-acceptance.mjs' +import { expectedCases, auditAcceptance, testedSourceCommit } from './parent-delivery-acceptance.mjs' +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { execFileSync } from 'node:child_process' test('全12組合せの必須実行面は欠落/skip/fixtureを製品実機passedにしない', () => { const expected = expectedCases() for (const os of ['darwin', 'linux', 'win32']) for (const harness of ['claude', 'codex', 'cursor', 'grok']) assert.ok(expected.some(item => item.os === os && item.harness === harness)) @@ -19,4 +23,22 @@ test('実機passedの宣言だけでは受けず、同source/会話/原文の証 assert.ok(auditAcceptance([actual], { readEvidence: () => evidence }).errors.some(error => error.id === item.id && error.code === 'PARENT_ACCEPTANCE_EVIDENCE_INVALID')) evidence.body_checks[0].received = structuredClone(original) assert.ok(!auditAcceptance([actual], { readEvidence: () => evidence }).errors.some(error => error.id === item.id)) + assert.ok(auditAcceptance([actual], { sourceDigest: '3'.repeat(64), readEvidence: () => evidence }).errors.some(error => error.id === item.id && error.code === 'PARENT_ACCEPTANCE_INVALID')) +}) +test('証拠保存commitは検証済みsourceを包含し、別branchのsourceは通さない', t => { + const repo = mkdtempSync(join(tmpdir(), 'peertable-source-provenance-')) + t.after(() => rmSync(repo, { recursive: true, force: true })) + const git = (...args) => execFileSync('git', args, { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() + git('init', '--quiet'); git('config', 'user.name', 'Peertable試験'); git('config', 'user.email', 'fixture@example.invalid') + mkdirSync(join(repo, 'room')); writeFileSync(join(repo, 'room', 'server.mjs'), 'export const value = 1\n'); git('add', 'room/server.mjs'); git('commit', '--quiet', '-m', '検証対象') + const tested = git('rev-parse', 'HEAD'), branch = git('branch', '--show-current') + writeFileSync(join(repo, 'evidence.json'), JSON.stringify({ source_commit: tested })); git('add', 'evidence.json'); git('commit', '--quiet', '-m', '実測証拠') + assert.notEqual(git('rev-parse', 'HEAD'), tested) + assert.equal(testedSourceCommit([{ source_commit: tested }], { repo }), tested) + writeFileSync(join(repo, 'room', 'server.mjs'), 'export const value = 2\n'); git('add', 'room/server.mjs'); git('commit', '--quiet', '-m', '製品変更') + assert.throws(() => testedSourceCommit([{ source_commit: tested }], { repo }), { code: 'PARENT_ACCEPTANCE_SOURCE_CHANGED' }) + git('checkout', '--quiet', '-b', '別試験', tested) + writeFileSync(join(repo, 'room', 'server.mjs'), 'export const value = 3\n'); git('add', 'room/server.mjs'); git('commit', '--quiet', '-m', '別source') + const unrelated = git('rev-parse', 'HEAD'); git('checkout', '--quiet', branch) + assert.throws(() => testedSourceCommit([{ source_commit: unrelated }], { repo }), { code: 'PARENT_ACCEPTANCE_SOURCE_NOT_INCLUDED' }) }) From 749f13f4b6080a204855a8a9d73ded3a3f88d4ac Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:13:23 +0900 Subject: [PATCH 06/72] =?UTF-8?q?Cursor=E3=81=A8Grok=E3=81=AE=E5=AE=9F?= =?UTF-8?q?=E8=B5=B7=E5=8B=95=E7=9B=B8=E9=96=A2=E3=81=A8=E8=A6=AA=E5=8F=97?= =?UTF-8?q?=E4=BF=A1=E3=81=AE=E7=B5=82=E4=BA=86=E5=87=A6=E7=90=86=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E7=90=86=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plan_parent-native-delivery.md | 4 +- rag/parent-delivery/receivers.md | 8 +- skill/scripts/parent-caller.mjs | 42 +++++-- skill/scripts/parent-connect.mjs | 7 ++ skill/scripts/parent-delivery.mjs | 15 ++- skill/scripts/parent-delivery.test.mjs | 106 +++++++++++++++++- skill/scripts/parent-hook.mjs | 5 +- skill/scripts/parent-platform.mjs | 32 +++++- skill/scripts/parent-receivers/background.mjs | 4 +- skill/scripts/parent-runtime.mjs | 13 ++- skill/scripts/parent-source-runtime.test.mjs | 20 +++- skill/scripts/parent-watch.mjs | 2 +- skill/scripts/teardown.mjs | 3 +- 13 files changed, 222 insertions(+), 39 deletions(-) diff --git a/docs/plan_parent-native-delivery.md b/docs/plan_parent-native-delivery.md index 190ba3a5..93bb395d 100644 --- a/docs/plan_parent-native-delivery.md +++ b/docs/plan_parent-native-delivery.md @@ -1,6 +1,6 @@ # 親セッションへの配送設計 -状態: 実装着手。allは親にも全件配送する。12組合せの実機受入は未実施。 +状態: 実装・CLI実測中。allは親にも全件配送する。12組合せの全条件の実機受入は未達。 ## 1. 依頼と完了条件 @@ -94,7 +94,7 @@ MCP requestと公式hookから実際の親を識別する。AIにthread IDやcon `parent_join`のreceiptは`schema: peertable.parent-join-result.v1`、`endpoint_id`、`state: binding_pending | receiving | verified | failed`、`harness`、`wait_process`、`error_code`を持つ。Cursor/Grokの結果返却後にhookで束縛する場合、最初のreceiptは`binding_pending`であり、成功やreadyと書かない。hookが返却結果の`endpoint_id`を実会話へ束縛した時点で`receiving`へ進み、購読とprobe配送を開始する。probeがその会話の受信経路を通った後に`verified`へ進める。`verified`を配送開始の条件にしてprobeを止める循環を作らない。進行は製品が続け、オーナーに手順の再実行を求めない。 -事前hookで実会話を照合できたjoinは、返却時点で`receiving`にできる。事後hookで初めて束縛するjoinだけが`binding_pending`を返す。旧watcherからの移行中は、束縛とreceiver準備が済んでも旧本文出力の停止が確認されるまで新watchとprobeを開始しない。束縛待ち・probe待ちはそれぞれ30秒を初期の明示期限とし、前者は`PARENT_BIND_TIMEOUT`、後者は`PARENT_PROBE_TIMEOUT`で失敗を残す。実機の開始時間を第1工程で測り、必要なら根拠を残して期限を調整する。無期限のpendingにしない。 +事前hookで実会話を照合できたjoinは、返却時点で`receiving`にできる。事後hookで初めて束縛するjoinだけが`binding_pending`を返す。旧watcherからの移行中は、束縛とreceiver準備が済んでも旧本文出力の停止が確認されるまで新watchとprobeを開始しない。束縛待ち・probe待ちはそれぞれ30秒を初期の明示期限とし、前者は`PARENT_BIND_TIMEOUT`、後者は`PARENT_PROBE_TIMEOUT`で失敗を残す。probe期限はnative receiverの準備確認後から計る。背景toolの登録前は、必要な登録入力を伴う`rearm_pending`として表示する。Windows Grokの実測ではモデルが登録toolを呼ぶまで数分かかり、join時点から計る旧実装の期限とは目的が異なった。遅れて確認された現在のprobeは、そのprobeの期限エラーだけを解除する。実機の開始時間を第1工程で測り、必要なら根拠を残して期限を調整する。無期限のpendingにしない。 登録の疎通実績と現在の受信継続は別に記録する。runtimeの受信状態は`armed | rearm_pending | stopped | failed`とし、背景taskが終わって次の待機が登録されるまでを`rearm_pending`として診断へ出す。Cursor/Grokではnative背景toolの呼出しと結果を公式hookで相関し、task ID・受信process identity・endpointを保存する。receiptを生成しただけでは`armed`にしない。再登録までに届いた本文は配送記録へ保持し、次の待機が拾う。永久に再登録されない状態を健康と表示しない。 diff --git a/rag/parent-delivery/receivers.md b/rag/parent-delivery/receivers.md index a9427326..9d3c047a 100644 --- a/rag/parent-delivery/receivers.md +++ b/rag/parent-delivery/receivers.md @@ -1,6 +1,6 @@ # 親セッションの公式受信口 -取得日: 2026-09-28。確度: Aitermのsource確認、公式仕様と配布資料の確認。Peertable新方式の実機成立は未検証。 +取得日: 2026-09-28。確度: Aitermのsource確認、公式仕様と配布資料の確認、固定snapshotのCLI実測。Peertable全対応の製品受入は未達。 この文書は調査記録である。実装要求は[配送設計](../../docs/plan_parent-native-delivery.md)を正本とする。Aitermの内部state・hook・moduleは参照資料だけであり、Peertableの実行時依存にしない。 @@ -36,7 +36,11 @@ 親の独立probeではClaude Stop継続とlease更新、Codex BUSY/STOP/IDLEが3 OSで成立した。sourceは[Claude Stop](../../experiments/parent-claude-stop-native/README.md)、[Claude lease](../../experiments/parent-claude-lease-native/README.md)、[Codex queue](../../experiments/parent-codex-queue-native/README.md)へ保存した。各`phase1-*.json`はprivate HOMEや認証設定を除いた投影である。Stop macOSとlease Linux/Windowsの投影はnative hook全文の原本回収が未完了であり、`native_hook_evidence_complete:false`を保持する。親の実測成立と、公開投影の完全性を同じ判定にしない。 -親の実製品snapshotではClaude 3 OSとCodex macOSのALL/DM/MULTIが同じ会話へ届き、room deliveredまで観測された。必要fieldだけを`product-snapshot-*.json`へ保存した。各runtime_digestは試験時の固定snapshotであり、最新実装と異なる。525件の最終受入へ算入しない。Cursor/Grokの6 CLIは親の別QAが継続中。GUI Cursorはactivation失敗でprompt未送信のため配送合否を出さない。Codex Desktop/IDE等のlive受入も残る。 +親の実製品snapshotではClaude 3 OSとCodex macOSのALL/DM/MULTIが同じ会話へ届き、room deliveredまで観測された。Codex Linux/Windowsではidle・queue空からALL/DM/MULTI/LONGの各新turnを確認した。40,097 UTF-16単位・104,151 UTF-8 bytesの長文は、送信本文・room応答・spool・公式queue本文をhash投影前に完全一致で照合した。長文のStop完了は新projectのLONG 1通だけで補足した。macOSのUTF-8修理後LONG 1通も原文一致と同会話の起床を確認した。必要fieldだけを`product-snapshot-*.json`へ保存した。各runtime_digestは試験時の固定snapshotであり、最新実装と異なる。525件の最終受入へ算入しない。 + +Cursor/Grokの6 CLIは旧snapshotで5件失敗、Windows Grokの無改造試験は未実施だった。診断copyの受信は製品成功へ算入しない。実processと公式hook入力を確認し、CursorのNode起動option、Grok native名、system祖先の境界、Windows CursorのBOM/UTF-8、afterMCPExecutionのendpoint相関、native receiver準備後のprobe期限を製品へ修理した。hook以外のtoken設定差はfixtureの隔離であり、標準credentialの取得先を変更しない。修理版で6 CLIを再測する。GUI試験は送信前に中止し、専用Cursorウィンドウ・room serverを閉じた。Codex Desktop/IDEとCursor Desktopのlive受入は残る。 + +親停止時は所有endpoint索引と終了したcallerの相関を撤去し、spool本文は保持する。共有する同会話のendpointと、生きた親の未消費要求を消さない。project用hookだけで参加した親はglobal接続を所有しない。teardownで共通hookを解除する対象は、自身のpackageのconnect記録に一致する親だけとする。 親のCodex idle長文試験でroomのUTF-8断片decodeによる原文破損が再現された。`readBody`をNodeのstream decoderへ修理し、[HTTP UTF-8再現](../../experiments/http-utf8-body-repro.mjs)で40,097 UTF-16文字の日本語・emojiの途中を5箇所で分割する。POST応答、GET messages、保存logの完全一致をfocused検証する。修理前のnative idle長文試験は失敗証拠として親が保管し、成功へ変更しない。 diff --git a/skill/scripts/parent-caller.mjs b/skill/scripts/parent-caller.mjs index 8063c43d..feb9eca1 100644 --- a/skill/scripts/parent-caller.mjs +++ b/skill/scripts/parent-caller.mjs @@ -1,5 +1,5 @@ // 公式hookとMCP要求を一度だけ消費する相関。時刻やcwdでは本人を選ばない。 -import { existsSync, renameSync, readdirSync } from 'node:fs' +import { existsSync, renameSync, readdirSync, rmSync } from 'node:fs' import { join } from 'node:path' import { realpathSync } from 'node:fs' import { randomUUID } from 'node:crypto' @@ -63,6 +63,27 @@ export function consumeCaller(harness, request, requestId) { export function registerEndpoint(spool) { atomicJson(join(parentHome(), 'endpoints', `${spool.id}.json`), { endpoint_id: spool.id, project: realpathSync(spool.project) }) } +export function forgetEndpoint(spool, { home = parentHome(), activeEndpoints = endpointsFor } = {}) { + const state = spool.read(), file = join(home, 'endpoints', `${spool.id}.json`) + if (existsSync(file)) { + const index = readJson(file) + if (index.endpoint_id !== spool.id || realpathSync(index.project) !== realpathSync(spool.project)) throw failure('PARENT_ENDPOINT_INDEX_MISMATCH') + rmSync(file) + } + const sameCaller = caller => caller?.harness === state.caller.harness && caller.conversation === state.caller.conversation && caller.owner?.pid === state.caller.owner.pid && caller.owner?.started === state.caller.owner.started + if (activeEndpoints({ caller: state.caller }).some(other => other.read().runtime !== 'stopped' && sameCaller(other.read().caller))) return + for (const kind of ['contexts', 'join-results']) { + const dir = join(home, kind) + if (!existsSync(dir)) continue + for (const name of readdirSync(dir).filter(name => /\.json(?:\.consumed-[0-9a-f-]{36})?$/u.test(name))) { + const entry = readJson(join(dir, name)), caller = kind === 'contexts' ? entry : entry.caller + if (!sameCaller(caller)) continue + // 生きた親の未消費要求は別roomのjoinかもしれない。消費済みか終了した親の相関だけを撤去する。 + if (kind === 'contexts' && !name.includes('.consumed-') && sameProcess(caller.owner)) continue + rmSync(join(dir, name)) + } + } +} export function endpointById(id) { if (!UUID.test(id ?? '')) throw failure('PARENT_ENDPOINT_INVALID') const file = join(parentHome(), 'endpoints', `${id}.json`) @@ -83,16 +104,12 @@ export function verifyCaller(spool, caller) { export function saveJoinResult(caller, result) { atomicJson(join(parentHome(), 'join-results', `${digest([caller.harness, caller.conversation, caller.use])}.json`), { caller, result }) } -export function verifyJoinHook(harness, event, owner) { +export function verifyJoinHook(harness, event, owner, { resolveEndpoint = endpointById } = {}) { const fullName = harness === 'grok' ? event.toolName : event.tool_name if (!/parent_join$/u.test(fullName ?? '')) return null if (harness === 'cursor' && event.hook_event_name === 'afterMCPExecution' && event.mcp_server_name !== 'peertable_parent') return null const conversation = harness === 'grok' ? event.sessionId : harness === 'cursor' ? event.conversation_id : event.session_id - const use = harness === 'grok' ? event.toolUseId : event.tool_use_id - if (!conversation || !use) throw failure('PARENT_CALLER_UNBOUND') - const file = join(parentHome(), 'join-results', `${digest([harness, conversation, use])}.json`) - if (!existsSync(file)) throw failure('PARENT_JOIN_RESULT_UNBOUND') - const saved = readJson(file) + if (!conversation) throw failure('PARENT_CALLER_UNBOUND') const find = value => { if (typeof value === 'string') { try { return find(JSON.parse(value)) } catch { return null } } if (!value || typeof value !== 'object') return null @@ -101,6 +118,17 @@ export function verifyJoinHook(harness, event, owner) { return null } const result = find(harness === 'grok' ? event.toolResult : event.tool_output ?? event.tool_response ?? event.result_json) + // CursorのafterMCPExecutionにはtool_use_idがない。MCPが返したendpointと実会話・PID開始identityで照合する。 + if (harness === 'cursor' && event.hook_event_name === 'afterMCPExecution') { + if (!result) throw failure('PARENT_JOIN_RESULT_MISMATCH') + verifyCaller(resolveEndpoint(result.endpoint_id), { harness, conversation, owner }) + return result + } + const use = harness === 'grok' ? event.toolUseId : event.tool_use_id + if (!use) throw failure('PARENT_CALLER_UNBOUND') + const file = join(parentHome(), 'join-results', `${digest([harness, conversation, use])}.json`) + if (!existsSync(file)) throw failure('PARENT_JOIN_RESULT_UNBOUND') + const saved = readJson(file) if (!result || result.endpoint_id !== saved.result.endpoint_id || owner.pid !== saved.caller.owner.pid || owner.started !== saved.caller.owner.started) throw failure('PARENT_JOIN_RESULT_MISMATCH') return result } diff --git a/skill/scripts/parent-connect.mjs b/skill/scripts/parent-connect.mjs index 48cdf7f3..d31282bd 100644 --- a/skill/scripts/parent-connect.mjs +++ b/skill/scripts/parent-connect.mjs @@ -32,6 +32,13 @@ export function hookEntries(target) { return Object.fromEntries(['PreToolUse', 'PostToolUse', 'SessionEnd'].map(name => [name, [{ hooks: [{ ...native, timeout: 20 }] }]])) } const commandIdentity = entry => JSON.stringify([entry.command, entry.args ?? []]) +export function ownsParentConnection(target, { home = parentHome() } = {}) { + const file = join(home, 'connections', `${target}.json`) + if (!existsSync(file)) return false + const record = readJson(file) + const commands = Object.values(hookEntries(target)).flatMap(groups => groups.flatMap(group => group.hooks ?? [group])).map(commandIdentity) + return record.status === 'registered' && commands.every(command => record.commands.includes(command)) +} export function mergeOwnedHooks(current, additions, ownedCommands) { const next = { ...current, hooks: { ...(current.hooks ?? {}) } } for (const name of new Set([...Object.keys(next.hooks), ...Object.keys(additions)])) { diff --git a/skill/scripts/parent-delivery.mjs b/skill/scripts/parent-delivery.mjs index 0fa617dd..1b86c58e 100644 --- a/skill/scripts/parent-delivery.mjs +++ b/skill/scripts/parent-delivery.mjs @@ -13,6 +13,10 @@ const canonical = value => value === null || typeof value !== 'object' ? JSON.st export const digest = value => createHash('sha256').update(typeof value === 'string' ? value : canonical(value)).digest('hex') export const PAGE_CHARS = 12000 export const eventKinds = ['parent_dm', 'parent_room_update', 'parent_lattice_error', 'parent_lattice_update', 'parent_table_stalled', 'parent_watch_snapshot', 'watch_error', 'parent_probe'] +export function armParentState(state) { + state.runtime = 'armed' + if (state.state !== 'verified' && state.probe_deadline == null) state.probe_deadline = Date.now() + 30000 +} export function withParentLock(root, fn) { const owner = processIdentity(process.pid) @@ -190,7 +194,13 @@ export class ParentSpool { if (queued_submission_id) saved.queued_submission_id = queued_submission_id if (accepted_at) saved.accepted_at = accepted_at saved.receipt = this.receiptFor(state, saved, saved.state === 'submitted' ? 'delivered' : saved.state, saved.state === 'unknown' && outcome === 'submitted' ? saved.receipt?.reason ?? reason : reason) - if (saved.event.type === 'parent_probe' && saved.state === 'submitted') state.state = 'verified' + if (saved.event.type === 'parent_probe' && saved.state === 'submitted') { + state.state = 'verified' + if (state.error_code === 'PARENT_PROBE_TIMEOUT') { + state.error_code = null + if (state.runtime === 'failed') state.runtime = state.harness === 'codex' || sameProcess(state.waiter?.owner) ? 'armed' : 'rearm_pending' + } + } return saved } token(record, offset) { @@ -280,7 +290,8 @@ export class ParentSpool { const waiter = { waiter_id: receipt?.waiter_id ?? randomUUID(), generation: receipt?.generation ?? randomUUID(), channel, owner: processIdentity(process.pid), started_at: now(), native_task: null } if (receipt && (state.wait_receipt?.waiter_id !== receipt.waiter_id || state.wait_receipt?.generation !== receipt.generation)) throw failure('PARENT_WAITER_MISMATCH') state.waiter = waiter - state.runtime = channel === 'claude_asyncRewake' ? 'armed' : 'rearm_pending' + if (channel === 'claude_asyncRewake') armParentState(state) + else state.runtime = 'rearm_pending' return waiter }) } diff --git a/skill/scripts/parent-delivery.test.mjs b/skill/scripts/parent-delivery.test.mjs index 5540c659..2f979277 100644 --- a/skill/scripts/parent-delivery.test.mjs +++ b/skill/scripts/parent-delivery.test.mjs @@ -1,16 +1,17 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { mkdtempSync, rmSync, mkdirSync, linkSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, mkdirSync, linkSync, writeFileSync, existsSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { spawn } from 'node:child_process' +import { Readable } from 'node:stream' import { randomUUID } from 'node:crypto' -import { ParentSpool, PAGE_CHARS, renderDelivery, digest } from './parent-delivery.mjs' -import { processIdentity, processHarness, atomicJson } from './parent-platform.mjs' +import { ParentSpool, PAGE_CHARS, renderDelivery, digest, armParentState } from './parent-delivery.mjs' +import { processIdentity, processHarness, harnessProcess, readHookEvent, atomicJson } from './parent-platform.mjs' import { queueCodex, codexHook, checkCodexReceiver } from './parent-receivers/codex.mjs' -import { mergeOwnedHooks, tomlHeaderKeys, replaceOwnedToml, ownedTomlBlock, hookEntries } from './parent-connect.mjs' +import { mergeOwnedHooks, tomlHeaderKeys, replaceOwnedToml, ownedTomlBlock, hookEntries, ownsParentConnection } from './parent-connect.mjs' import { cursorEvent } from './parent-receivers/cursor.mjs' -import { clientHarness, hookContext } from './parent-caller.mjs' +import { clientHarness, hookContext, verifyJoinHook, forgetEndpoint } from './parent-caller.mjs' const fixture = t => { const project = mkdtempSync(join(tmpdir(), 'peertable spool 日本語 ')) t.after(() => rmSync(project, { recursive: true, force: true })) @@ -146,6 +147,101 @@ test('未知clientとshell引数内のharness名を本人へ推測割当しな assert.equal(processHarness({ executable: '/usr/bin/node', command: 'node /global/node_modules/@openai/codex/bin/codex.js app-server' }), 'codex') }) +test('CursorのNode optionとGrok公式native名を認識し、preloadや評価式を本人にしない', () => { + const node = process.execPath + const cursor = '/home/kite/.local/share/cursor-agent/versions/2026.09.26-dd393fe/index.js' + assert.equal(processHarness({ executable: node, command: `node --use-system-ca ${cursor} --model auto` }), 'cursor') + assert.equal(processHarness({ executable: node, command: `node --require ${cursor} script.mjs` }), null) + assert.equal(processHarness({ executable: node, command: `node --eval "${cursor}"` }), null) + assert.equal(processHarness({ executable: '/home/kite/.grok/downloads/grok-linux-x86_64', command: 'grok --always-approve' }), 'grok') + assert.equal(processHarness({ executable: '/home/kite/.grok/downloads/grok-macos-aarch64', command: 'grok' }), 'grok') + assert.equal(processHarness({ executable: '/tmp/grok-unknown-name', command: 'grok' }), null) +}) + +test('harness探索はPID 1とexecutableのないsystem祖先を照会しない', () => { + const calls = [] + assert.equal(harnessProcess('grok', 2, { identify: (pid, options) => { + calls.push([pid, options?.includeExecutable !== false]) + assert.notEqual(pid, 1) + return { pid, parent: 1, started: 'fixture', executable: '/bin/sh', command: 'sh' } + } }), null) + assert.deepEqual(calls, [[2, false], [2, true]]) + assert.equal(harnessProcess('cursor', 4, { identify: (pid, options) => { + assert.equal(options?.includeExecutable, false) + return { pid, parent: 0, started: 'system', executable: null, command: null } + } }), null) +}) + +test('公式hookのBOMとUTF-8文字途中の分割を受け、壊れたJSONは明示失敗する', async () => { + const expected = { hook_event_name: 'preToolUse', conversation_id: '会話😀', tool_input: { body: '日本語\n「引用」😀' } } + const bytes = Buffer.from(`\uFEFF${JSON.stringify(expected)}`) + assert.deepEqual(await readHookEvent(Readable.from([...bytes].map(byte => Buffer.from([byte])))), expected) + await assert.rejects(readHookEvent(Readable.from([Buffer.from('{破損')])) , { code: 'PARENT_HOOK_INPUT_INVALID' }) +}) + +test('Cursor afterMCPExecutionはtool_use_idなしでもendpointと実会話・開始identityを照合する', t => { + const spool = fixture(t), owner = processIdentity(process.pid) + spool.update({ caller: { harness: 'cursor', conversation: 'この会話', owner } }) + const result = { schema: 'peertable.parent-join-result.v1', endpoint_id: spool.id } + const event = { hook_event_name: 'afterMCPExecution', conversation_id: 'この会話', mcp_server_name: 'peertable_parent', tool_name: 'parent_join', result_json: JSON.stringify({ content: [{ type: 'text', text: JSON.stringify(result) }] }) } + const options = { resolveEndpoint: id => { assert.equal(id, spool.id); return spool } } + assert.deepEqual(verifyJoinHook('cursor', event, owner, options), result) + assert.throws(() => verifyJoinHook('cursor', { ...event, conversation_id: '別会話' }, owner, options), { code: 'PARENT_CALLER_MISMATCH' }) + assert.throws(() => verifyJoinHook('cursor', event, { ...owner, started: '別開始' }, options), { code: 'PARENT_CALLER_MISMATCH' }) + assert.equal(verifyJoinHook('cursor', { ...event, mcp_server_name: 'other' }, owner, options), null) +}) + +test('probe期限は受信準備から始め、確認済みprobeだけが自身の期限エラーを解除する', t => { + const spool = fixture(t) + spool.update({ created_at: '2000-01-01T00:00:00.000Z' }) + spool.slot('grok_background') + assert.equal(spool.read().probe_deadline, undefined) + const before = Date.now() + spool.transact(armParentState) + assert.ok(spool.read().probe_deadline >= before + 30000) + const probe = spool.saveEvent({ type: 'parent_probe', event_id: 'probe', body: '疎通確認' }) + const claimed = spool.claim('grok_background', probe.delivery_id) + spool.update({ state: 'failed', runtime: 'failed', error_code: 'PARENT_PROBE_TIMEOUT' }) + spool.finish(claimed) + assert.equal(spool.read().state, 'verified') + assert.equal(spool.read().runtime, 'armed') + assert.equal(spool.read().error_code, null) + const other = spool.saveEvent(event(9)), record = spool.claim('grok_background', other.delivery_id) + spool.update({ error_code: 'PARENT_PROCESS_API_FAILED' }); spool.finish(record) + assert.equal(spool.read().error_code, 'PARENT_PROCESS_API_FAILED') +}) + +test('project hookの親は別packageのglobal接続を所有せず、解除済み接続も撤去しない', t => { + const spool = fixture(t), home = join(spool.project, 'receiver-home'), file = join(home, 'connections', 'cursor.json') + assert.equal(ownsParentConnection('cursor', { home }), false) + atomicJson(file, { status: 'registered', commands: ['他packageのhook'] }) + assert.equal(ownsParentConnection('cursor', { home }), false) + const commands = Object.values(hookEntries('cursor')).flatMap(groups => groups.map(group => JSON.stringify([group.command, []]))) + atomicJson(file, { status: 'registered', commands }) + assert.equal(ownsParentConnection('cursor', { home }), true) + atomicJson(file, { status: 'removed', commands }) + assert.equal(ownsParentConnection('cursor', { home }), false) +}) + +test('親の撤去は本文を保持し、共有会話・未消費要求・別の開始identityを消さない', t => { + const spool = fixture(t), home = join(spool.project, 'receiver-home'), index = join(home, 'endpoints', `${spool.id}.json`) + spool.saveEvent(event(1)); spool.update({ runtime: 'stopped' }) + const caller = spool.read().caller, consumed = join(home, 'contexts', `own.json.consumed-${randomUUID()}`) + const pending = join(home, 'contexts', 'pending.json'), foreign = join(home, 'contexts', 'foreign.json'), joined = join(home, 'join-results', 'own.json') + atomicJson(index, { endpoint_id: spool.id, project: spool.project }) + atomicJson(consumed, caller); atomicJson(pending, caller); atomicJson(foreign, { ...caller, owner: { ...caller.owner, started: '別の開始' } }); atomicJson(joined, { caller }) + forgetEndpoint(spool, { home, activeEndpoints: () => [{ read: () => ({ runtime: 'armed', caller }) }] }) + assert.equal(existsSync(index), false); assert.equal(existsSync(consumed), true) + forgetEndpoint(spool, { home, activeEndpoints: () => [] }) + assert.equal(existsSync(consumed), false); assert.equal(existsSync(joined), false) + assert.equal(existsSync(pending), true); assert.equal(existsSync(foreign), true) + assert.equal(spool.read().records[0].event.body, event(1).body) + const stoppedCaller = { ...caller, owner: { ...caller.owner, started: '終了した開始' } } + spool.update({ caller: stoppedCaller }); atomicJson(pending, stoppedCaller) + forgetEndpoint(spool, { home, activeEndpoints: () => [] }) + assert.equal(existsSync(pending), false); assert.equal(existsSync(foreign), true) +}) + test('Claude agent_id付き会話は相関保存前に明示拒否する', () => { assert.throws(() => hookContext('claude', { agent_id: 'native-agent', session_id: 'session', tool_use_id: 'use', tool_name: 'parent_join', tool_input: {} }), { code: 'CLAUDE_PARENT_SUBAGENT_UNSUPPORTED' }) }) diff --git a/skill/scripts/parent-hook.mjs b/skill/scripts/parent-hook.mjs index 77c054ff..62938cf9 100644 --- a/skill/scripts/parent-hook.mjs +++ b/skill/scripts/parent-hook.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node // Peertable所有hook。互換読込みも実processのハーネスと照合して1 adapterだけ動かす。 import { hookContext, endpointsFor, verifyJoinHook, assertClaudeParent } from './parent-caller.mjs' -import { harnessProcess, failure } from './parent-platform.mjs' +import { harnessProcess, failure, readHookEvent } from './parent-platform.mjs' import { claudeRewake } from './parent-receivers/claude.mjs' import { cursorEvent } from './parent-receivers/cursor.mjs' import { bindNativeWait } from './parent-receivers/background.mjs' @@ -12,8 +12,7 @@ const harness = process.argv[2] const writeJson = value => new Promise((resolve, reject) => process.stdout.write(`${JSON.stringify(value)}\n`, error => error ? reject(error) : resolve())) const writeError = value => new Promise((resolve, reject) => process.stderr.write(`${value}\n`, error => error ? reject(error) : resolve())) try { - let raw = ''; for await (const chunk of process.stdin) raw += chunk - const event = JSON.parse(raw) + const event = await readHookEvent(process.stdin) if (harness === 'claude') assertClaudeParent(event) const eventName = harness === 'grok' ? event.hook_event_name : event.hook_event_name const pre = eventName === (harness === 'cursor' ? 'preToolUse' : 'PreToolUse') diff --git a/skill/scripts/parent-platform.mjs b/skill/scripts/parent-platform.mjs index cadf2f10..a84ba910 100644 --- a/skill/scripts/parent-platform.mjs +++ b/skill/scripts/parent-platform.mjs @@ -9,6 +9,13 @@ import { randomUUID } from 'node:crypto' export const parentHome = () => join(homedir(), '.peertable', 'parent-receivers') export const failure = (code, detail = code) => Object.assign(new Error(detail), { code }) export const readJson = file => JSON.parse(readFileSync(file, 'utf8')) +export async function readHookEvent(stream) { + // Windows CursorはBOMを付ける。stream decoderでUTF-8の文字途中の分割も扱う。 + stream.setEncoding('utf8') + let raw = ''; for await (const chunk of stream) raw += chunk + try { return JSON.parse(raw.replace(/^\uFEFF/u, '')) } + catch { throw failure('PARENT_HOOK_INPUT_INVALID', '公式hookのJSON入力を解析できません') } +} export function atomicJson(file, value) { mkdirSync(dirname(file), { recursive: true, mode: 0o700 }) const temp = `${file}.${process.pid}.${randomUUID()}.tmp` @@ -91,19 +98,36 @@ export function processHarness(identity) { const executable = basename(identity.executable ?? '').toLowerCase() const native = { 'claude': 'claude', 'claude.exe': 'claude', 'codex': 'codex', 'codex.exe': 'codex', 'grok': 'grok', 'grok.exe': 'grok', 'cursor-agent': 'cursor', 'cursor.exe': 'cursor', 'cursor': 'cursor' } if (native[executable]) return native[executable] + if (/^grok-(?:macos|linux)-(?:aarch64|x86_64)$/u.test(executable)) return 'grok' if (executable !== 'node' && executable !== 'node.exe') return null // Node直下の実entryだけを読む。shell -cや後続引数に含まれる名前は本人の根拠にしない。 const tokens = (identity.command ?? '').match(/"[^"]*"|'[^']*'|[^\s]+/gu) ?? [] - const script = tokens[1]?.replace(/^['"]|['"]$/gu, '') ?? '' + let index = 1 + // 公式CursorはNodeの--use-system-caをentryの前に置く。評価式やpreloadはmain scriptとして扱わない。 + const flags = new Set(['--use-system-ca', '--no-warnings', '--no-deprecation', '--enable-source-maps']) + const valued = new Set(['--require', '-r', '--import', '--conditions', '-C']) + for (; index < tokens.length; index++) { + const token = tokens[index] + if (token === '--') { index++; break } + if (flags.has(token)) continue + if (valued.has(token)) { index++; continue } + if ([...valued].some(flag => token.startsWith(`${flag}=`))) continue + if (token.startsWith('-')) return null + break + } + const script = tokens[index]?.replace(/^['"]|['"]$/gu, '') ?? '' if (/@anthropic-ai[/\\]claude-code[/\\](?:cli\.js|bin[/\\]claude)/u.test(script)) return 'claude' if (/@openai[/\\]codex[/\\]bin[/\\]codex\.js$/u.test(script)) return 'codex' if (/cursor-agent[/\\]versions[/\\][^/\\]+[/\\](?:index\.js|cursor-agent)$/u.test(script)) return 'cursor' if (/grok-cli[/\\](?:dist[/\\])?(?:index|cli)\.js$/u.test(script)) return 'grok' return null } -export function harnessProcess(harness, start = process.ppid) { - for (let pid = start, depth = 0; pid > 0 && depth < 24; depth++) { - const identity = processIdentity(pid) +export function harnessProcess(harness, start = process.ppid, { identify = processIdentity } = {}) { + for (let pid = start, depth = 0; pid > 1 && depth < 24; depth++) { + // system/initの親を本人候補にしない。Windowsのsystem processにExecutablePathが無くても所有者探索は終了できる。 + const ancestry = identify(pid, { includeExecutable: false }) + if (!ancestry || ancestry.parent <= 0 || ancestry.parent === pid) return null + const identity = identify(pid) if (!identity) return null const detected = processHarness(identity) if (detected) return detected === harness ? identity : null diff --git a/skill/scripts/parent-receivers/background.mjs b/skill/scripts/parent-receivers/background.mjs index 348dc2c3..c8ae422d 100644 --- a/skill/scripts/parent-receivers/background.mjs +++ b/skill/scripts/parent-receivers/background.mjs @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto' import { fileURLToPath } from 'node:url' import { setTimeout as delay } from 'node:timers/promises' import { sameProcess, processIdentity, processDescendsFrom, shellCommand, hookCommand, windowsArgumentList, failure } from '../parent-platform.mjs' -import { PAGE_CHARS, renderDelivery } from '../parent-delivery.mjs' +import { PAGE_CHARS, renderDelivery, armParentState } from '../parent-delivery.mjs' const entry = fileURLToPath(new URL('../parent-receive.mjs', import.meta.url)) export function waitReceipt(spool) { @@ -45,7 +45,7 @@ export async function bindNativeWait(spool, event, harness) { const taskOwner = processIdentity(pid) if (!taskOwner || !processDescendsFrom(waiter.owner, taskOwner)) throw failure('PARENT_NATIVE_WAIT_PROCESS_MISMATCH') waiter.native_task = { id: String(task), pid, process_identity: taskOwner, input: expected, at: new Date().toISOString() } - saved.runtime = 'armed' + armParentState(saved) }) await spool.publishHealth() return true diff --git a/skill/scripts/parent-runtime.mjs b/skill/scripts/parent-runtime.mjs index 9ac760d3..2db1ac83 100644 --- a/skill/scripts/parent-runtime.mjs +++ b/skill/scripts/parent-runtime.mjs @@ -7,7 +7,8 @@ import { setTimeout as delay } from 'node:timers/promises' import { randomUUID } from 'node:crypto' import { ParentSpool } from './parent-delivery.mjs' import { atomicJson, readJson, processIdentity, sameProcess, failure, posixQuote, psQuote } from './parent-platform.mjs' -import { registerEndpoint, endpointsFor } from './parent-caller.mjs' +import { registerEndpoint, endpointsFor, forgetEndpoint } from './parent-caller.mjs' +import { ownsParentConnection } from './parent-connect.mjs' import { RoomApi } from './room-api.mjs' import { runtimeDigest } from './runtime-digest.mjs' @@ -58,6 +59,7 @@ export async function stopEndpoint(spool) { record.state = 'failed'; record.error_code = 'PARENT_SESSION_CLOSED'; record.receipt = spool.receiptFor(saved, record, 'failed', record.error_code) } }) + forgetEndpoint(spool) } export async function migrateLegacy(spool, api) { const file = join(spool.project, '.team', 'parent-watch.json') @@ -125,16 +127,19 @@ export async function joinEndpoint(projectArg, name, caller, display = {}) { const previous = projectEndpoints(project).find(spool => { const saved = spool.read(); return saved.name === name && saved.caller.harness === caller.harness && saved.caller.conversation === caller.conversation }) let spool = previous if (spool) { - if (!Number.isSafeInteger(spool.read().cursor)) throw failure('PARENT_CURSOR_MISSING') - spool.update({ caller, runtime: spool.read().waiter && sameProcess(spool.read().waiter.owner) ? spool.read().runtime : 'rearm_pending', state: spool.read().state === 'verified' ? 'verified' : 'receiving', probe_deadline: Date.now() + 30000, error_code: null }) + const saved = spool.read() + if (!Number.isSafeInteger(saved.cursor)) throw failure('PARENT_CURSOR_MISSING') + const runtime = caller.harness === 'codex' ? 'armed' : saved.waiter && sameProcess(saved.waiter.owner) ? saved.runtime : 'rearm_pending' + spool.update({ caller, runtime, state: saved.state === 'verified' ? 'verified' : 'receiving', probe_deadline: runtime === 'armed' && saved.state !== 'verified' ? Date.now() + 30000 : null, error_code: null }) } else { for (const old of projectEndpoints(project).filter(item => item.read().name === name && item.read().runtime !== 'stopped')) await stopEndpoint(old) const summary = await api.request('summary') if (!Number.isSafeInteger(summary.seq)) throw failure('PARENT_START_SEQ_INVALID') spool = ParentSpool.create(project, { name, harness: caller.harness, caller, room: setup.room, server_url: setup.server_url.replace(/\/$/u, ''), start_seq: summary.seq, credential: api.credential }) - registerEndpoint(spool) if ((await api.members()).some(member => member.name === name && member.delivery?.kind === 'parent_watch')) await migrateLegacy(spool, api) } + registerEndpoint(spool) + spool.update({ global_connection: ownsParentConnection(caller.harness) }) await api.request('members', { method: 'POST', body: parentMemberRecord(name, caller.harness, spool.id, display) }) actorEnvironment(project, name) await startEndpoint(spool) diff --git a/skill/scripts/parent-source-runtime.test.mjs b/skill/scripts/parent-source-runtime.test.mjs index ddf5a799..2baf150f 100644 --- a/skill/scripts/parent-source-runtime.test.mjs +++ b/skill/scripts/parent-source-runtime.test.mjs @@ -17,8 +17,7 @@ const waitFor = async condition => { } test('SSEが正常で新seqが無くてもreceipt retry/孤児回収/probe期限/healthが進む', async t => { const project = mkdtempSync(join(tmpdir(), 'peertable-source-runtime-')) - t.after(() => rmSync(project, { recursive: true, force: true })) - let spool, eventsConnected = false, receiptFailures = 0 + let spool, watcher, eventsConnected = false, receiptFailures = 0 const receipts = [], health = [], streams = new Set() const server = createServer(async (req, res) => { let raw = ''; for await (const chunk of req) raw += chunk @@ -39,17 +38,26 @@ test('SSEが正常で新seqが無くてもreceipt retry/孤児回収/probe期限 } res.statusCode = 404; json({ error: 'fixture_path_unknown' }) }) + t.after(async () => { + // 購読processを先に止める。SSEを先に閉じると再接続がserver.closeと競合する。 + if (watcher?.exitCode === null) { watcher.kill(); await once(watcher, 'exit') } + for (const stream of streams) stream.destroy() + await new Promise(resolve => server.close(resolve)) + rmSync(project, { recursive: true, force: true }) + }) await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) - t.after(async () => { for (const stream of streams) stream.destroy(); await new Promise(resolve => server.close(resolve)) }) const base = `http://127.0.0.1:${server.address().port}`, credential = join(project, 'credential') writeFileSync(credential, 'fixture', { mode: 0o600 }) spool = ParentSpool.create(project, { name: 'parent', room: 'fixture', server_url: base, credential, start_seq: 0, harness: 'cursor', caller: { conversation: 'fixture', owner: processIdentity(process.pid) } }) atomicJson(join(project, '.team', 'setup-state.json'), { room: 'fixture', server_url: base }) - spool.update({ state: 'verified', runtime: 'armed' }) - const watcher = spawn(process.execPath, [fileURLToPath(new URL('./parent-watch.mjs', import.meta.url)), project, 'parent', '--deliver', spool.id], { stdio: ['ignore', 'ignore', 'pipe'] }) + spool.update({ state: 'receiving', runtime: 'rearm_pending', created_at: '2000-01-01T00:00:00.000Z' }) + watcher = spawn(process.execPath, [fileURLToPath(new URL('./parent-watch.mjs', import.meta.url)), project, 'parent', '--deliver', spool.id], { stdio: ['ignore', 'ignore', 'pipe'] }) let stderr = ''; watcher.stderr.on('data', chunk => { stderr += chunk }) - t.after(async () => { if (watcher.exitCode === null) { watcher.kill(); await once(watcher, 'exit') } }) await waitFor(() => eventsConnected && health.length > 0) + assert.equal(spool.read().state, 'receiving') + assert.equal(spool.read().error_code, undefined) + assert.ok(health.some(item => item.state === 'rearm_pending')) + spool.update({ state: 'verified', runtime: 'armed' }) const sent = spool.saveEvent({ type: 'parent_dm', seq: 1, body: '出力済み本文' }), claimed = spool.claim('fixture', sent.delivery_id) spool.finish(claimed) receiptFailures = 1 diff --git a/skill/scripts/parent-watch.mjs b/skill/scripts/parent-watch.mjs index 771fc500..c5ae29a9 100644 --- a/skill/scripts/parent-watch.mjs +++ b/skill/scripts/parent-watch.mjs @@ -371,7 +371,7 @@ async function maintainReceiver() { catch (error) { process.stderr.write(`${error.code}: ${error.message}\n`) } if (spool.read().harness === 'codex') while (await queueCodex(spool)) {} const status = spool.read() - if (status.state !== 'verified' && Date.now() > (status.probe_deadline ?? Date.parse(status.created_at) + 30000)) spool.update({ state: 'failed', runtime: 'failed', error_code: 'PARENT_PROBE_TIMEOUT' }) + if (status.state !== 'verified' && status.probe_deadline != null && Date.now() > status.probe_deadline) spool.update({ state: 'failed', runtime: 'failed', error_code: 'PARENT_PROBE_TIMEOUT' }) await spool.publishHealth() } } diff --git a/skill/scripts/teardown.mjs b/skill/scripts/teardown.mjs index 7e337cd7..8074bb27 100644 --- a/skill/scripts/teardown.mjs +++ b/skill/scripts/teardown.mjs @@ -91,7 +91,8 @@ export async function teardownProject(options, dependencies = {}) { cpSync(join(team, 'parent-delivery'), archive, { recursive: true }) return archive }) - for (const harness of new Set(parentEndpoints.map(endpoint => endpoint.read().harness))) { + // project用hookだけで参加した親はglobal接続を所有しない。共通設定の解除は自身のconnect記録がある親だけ。 + for (const harness of new Set(parentEndpoints.filter(endpoint => endpoint.read().global_connection === true).map(endpoint => endpoint.read().harness))) { const shared = endpointsFor().some(endpoint => endpoint.project !== project && endpoint.read().harness === harness && endpoint.read().runtime !== 'stopped') if (!shared) await step(`親接続 ${harness} の解除`, () => connectParent(harness, { remove: true })) } From dd5fbf41b84058d2e10a247374041bb89a54fc43 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:13:24 +0900 Subject: [PATCH 07/72] =?UTF-8?q?=E5=9B=BA=E5=AE=9Asnapshot=E3=81=A7?= =?UTF-8?q?=E3=81=AECodex=20native=E8=B5=B7=E5=BA=8A=E3=81=A8=E9=95=B7?= =?UTF-8?q?=E6=96=87=E5=8E=9F=E6=96=87=E3=81=AE=E8=A8=BC=E6=8B=A0=E3=82=92?= =?UTF-8?q?=E4=BF=9D=E6=8C=81=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product-snapshot-codex-darwin-long.json | 175 ++ .../product-snapshot-codex-linux.json | 1405 +++++++++++++++++ .../product-snapshot-codex-win32.json | 1361 ++++++++++++++++ 3 files changed, 2941 insertions(+) create mode 100644 rag/parent-delivery/product-snapshot-codex-darwin-long.json create mode 100644 rag/parent-delivery/product-snapshot-codex-linux.json create mode 100644 rag/parent-delivery/product-snapshot-codex-win32.json diff --git a/rag/parent-delivery/product-snapshot-codex-darwin-long.json b/rag/parent-delivery/product-snapshot-codex-darwin-long.json new file mode 100644 index 00000000..67c839d5 --- /dev/null +++ b/rag/parent-delivery/product-snapshot-codex-darwin-long.json @@ -0,0 +1,175 @@ +{ + "schema": "peertable.parent-product-native-snapshot-projection.v1", + "source_status": "UTF-8修理後の固定snapshotによるLONG 1通のnative idle観測。最終525件製品受入へ算入しない。", + "platform": "darwin", + "surface": "codex-cli-app-server", + "room": "parent-codex-6e386b7b-c2d9-448b-9ec2-b43cf801ad12", + "thread": "01a0e8a6-8e04-7bd2-b93d-0da16943e04d", + "endpoint": "01144806-26fd-4b6a-9c0a-8b7664ebfdf4", + "runtime_digest": "3fc3484d597bd326539f4b102e346c8386562d35c2cdbfe7e5dbce0b46863161", + "snapshot": null, + "cli_version": null, + "node_version": null, + "idle_checks": [ + { + "label": "beforeALL", + "at": "2026-09-28T15:34:27.087Z", + "thread": "01a0e8a6-8e04-7bd2-b93d-0da16943e04d", + "status": { + "type": "idle" + } + }, + { + "label": "beforeLONG", + "at": "2026-09-28T15:34:28.589Z", + "thread": "01a0e8a6-8e04-7bd2-b93d-0da16943e04d", + "status": { + "type": "idle" + } + } + ], + "checks": [ + { + "label": "LONG", + "seq": 3, + "nonce": "PEERTABLE_PRODUCT_CODEX_LONG_acf64911-70fd-480d-9a3b-89aba91cc453", + "from": "probe", + "audience": "all", + "raw_equal": true, + "body_sha256": "5a0913af79aabeb616592ca19302da4d2e70015f3979f40f29b3b26940ce4e11", + "queue_body_sha256": "5a0913af79aabeb616592ca19302da4d2e70015f3979f40f29b3b26940ce4e11", + "unicode_codepoints": 36096, + "utf16_code_units": 40097, + "utf8_bytes": 104151, + "body": null, + "queued_submission_id": "01a0e8a7-3a2c-7c32-bbe4-ec593e1ea8d8", + "accepted_at": "2026-09-28T15:34:28.655Z", + "receipt": { + "state": "delivered", + "reason": "codex_queue_accepted", + "at": "2026-09-28T15:34:28.662Z", + "queued_submission_id": "01a0e8a7-3a2c-7c32-bbe4-ec593e1ea8d8", + "accepted_at": "2026-09-28T15:34:28.655Z", + "receipt_revision": 1 + }, + "reply": { + "thread": "01a0e8a6-8e04-7bd2-b93d-0da16943e04d", + "turn": "01a0e8a7-4f81-7a13-a32f-2516ea6664f1", + "text": "room観測データを受信しました。\n\n- 配送確認符号:`PEERTABLE_PRODUCT_CODEX_LONG_acf64911-70fd-480d-9a3b-89aba91cc453`\n- 送信者:`probe`\n- 宛先:`all`\n- seq:`3`\n- 配送ID:`9b32f0ca-c2ee-4d30-8e25-19e0a1b03fd6`" + }, + "same_cid_new_turn": true, + "native_hooks": [ + { + "method": "hook/started", + "thread": "01a0e8a6-8e04-7bd2-b93d-0da16943e04d", + "turn": "01a0e8a7-4f81-7a13-a32f-2516ea6664f1", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609674, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8e04-7bd2-b93d-0da16943e04d", + "turn": "01a0e8a7-4f81-7a13-a32f-2516ea6664f1", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609674, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8e04-7bd2-b93d-0da16943e04d", + "turn": "01a0e8a7-4f81-7a13-a32f-2516ea6664f1", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609674, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8e04-7bd2-b93d-0da16943e04d", + "turn": "01a0e8a7-4f81-7a13-a32f-2516ea6664f1", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609674, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8e04-7bd2-b93d-0da16943e04d", + "turn": "01a0e8a7-4f81-7a13-a32f-2516ea6664f1", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609674, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8e04-7bd2-b93d-0da16943e04d", + "turn": "01a0e8a7-4f81-7a13-a32f-2516ea6664f1", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609674, + "completed_at": 1790609674, + "duration_ms": 250 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8e04-7bd2-b93d-0da16943e04d", + "turn": "01a0e8a7-4f81-7a13-a32f-2516ea6664f1", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609674, + "completed_at": 1790609674, + "duration_ms": 228 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8e04-7bd2-b93d-0da16943e04d", + "turn": "01a0e8a7-4f81-7a13-a32f-2516ea6664f1", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609674, + "completed_at": 1790609674, + "duration_ms": 127 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8e04-7bd2-b93d-0da16943e04d", + "turn": "01a0e8a7-4f81-7a13-a32f-2516ea6664f1", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609674, + "completed_at": 1790609674, + "duration_ms": 111 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8e04-7bd2-b93d-0da16943e04d", + "turn": "01a0e8a7-4f81-7a13-a32f-2516ea6664f1", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609674, + "completed_at": 1790609674, + "duration_ms": 318 + } + ] + } + ] +} diff --git a/rag/parent-delivery/product-snapshot-codex-linux.json b/rag/parent-delivery/product-snapshot-codex-linux.json new file mode 100644 index 00000000..d61a5a30 --- /dev/null +++ b/rag/parent-delivery/product-snapshot-codex-linux.json @@ -0,0 +1,1405 @@ +{ + "schema": "peertable.parent-product-native-snapshot-projection.v1", + "source_status": "固定snapshotのnative観測。525件の最終製品受入へ算入しない。長文本文は原本のhash投影。", + "platform": "linux", + "surface": "codex-cli-app-server", + "room": "parent-codex-df3d68a6-6301-4462-b710-b3f0e693734a", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "endpoint": "aeee7de8-1b0d-4127-b450-178691c000b1", + "runtime_digest": "3fc3484d597bd326539f4b102e346c8386562d35c2cdbfe7e5dbce0b46863161", + "snapshot": { + "source_path": "/Users/kite/.codex/worktrees/aiterm-unified-delivery/peertable", + "source_digest_before": "3fc3484d597bd326539f4b102e346c8386562d35c2cdbfe7e5dbce0b46863161", + "source_digest_after": "3fc3484d597bd326539f4b102e346c8386562d35c2cdbfe7e5dbce0b46863161", + "snapshot_runtime_digest": "3fc3484d597bd326539f4b102e346c8386562d35c2cdbfe7e5dbce0b46863161", + "source_stable": true, + "source_commit": "7642b697cabb95c3fb9ca1b4cb323eae02973445", + "package_version": "0.8.62", + "room_utf8_decoder_present": true + }, + "cli_version": "codex-cli 0.157.1", + "node_version": "v24.20.0", + "idle_checks": [ + { + "label": "beforeALL", + "at": "2026-09-28T15:34:24.884Z", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "status": { + "type": "idle" + } + }, + { + "label": "beforeALL", + "at": "2026-09-28T15:34:26.386Z", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "status": { + "type": "idle" + } + }, + { + "label": "beforeDM", + "at": "2026-09-28T15:34:33.912Z", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "status": { + "type": "idle" + } + }, + { + "label": "beforeMULTI", + "at": "2026-09-28T15:34:44.842Z", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "status": { + "type": "idle" + } + }, + { + "label": "beforeLONG", + "at": "2026-09-28T15:34:53.473Z", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "status": { + "type": "idle" + } + } + ], + "checks": [ + { + "label": "ALL", + "seq": 3, + "nonce": "PEERTABLE_PRODUCT_CODEX_ALL_25487a9c-0e6f-42a9-a71a-a53422f22886", + "from": "probe", + "audience": "all", + "raw_equal": true, + "body_sha256": "2106c2aa63f6c0a99ae7fadac56bc98e3acbc082c758adad58b5ce6307d1bb2e", + "queue_body_sha256": "2106c2aa63f6c0a99ae7fadac56bc98e3acbc082c758adad58b5ce6307d1bb2e", + "unicode_codepoints": 90, + "utf16_code_units": 91, + "utf8_bytes": 135, + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CODEX_ALL_25487a9c-0e6f-42a9-a71a-a53422f22886 です。", + "queued_submission_id": "01a0e8a7-31c2-72f3-8f13-2c96104dc1f0", + "accepted_at": "2026-09-28T15:34:26.505Z", + "receipt": { + "state": "delivered", + "reason": "codex_queue_accepted", + "at": "2026-09-28T15:34:26.511Z", + "queued_submission_id": "01a0e8a7-31c2-72f3-8f13-2c96104dc1f0", + "accepted_at": "2026-09-28T15:34:26.505Z", + "receipt_revision": 1 + }, + "reply": { + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-379c-7200-8660-4db3bd1ee09c", + "text": "配送確認の符号は `PEERTABLE_PRODUCT_CODEX_ALL_25487a9c-0e6f-42a9-a71a-a53422f22886` です。送信者は `probe`、宛先は `all`、seqは `3` です。受信を維持します。" + }, + "same_cid_new_turn": true, + "native_hooks": [ + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-379c-7200-8660-4db3bd1ee09c", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609668, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-379c-7200-8660-4db3bd1ee09c", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609668, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-379c-7200-8660-4db3bd1ee09c", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609668, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-379c-7200-8660-4db3bd1ee09c", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609668, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-379c-7200-8660-4db3bd1ee09c", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609668, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-379c-7200-8660-4db3bd1ee09c", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609668, + "completed_at": 1790609668, + "duration_ms": 77 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-379c-7200-8660-4db3bd1ee09c", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609668, + "completed_at": 1790609668, + "duration_ms": 190 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-379c-7200-8660-4db3bd1ee09c", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609668, + "completed_at": 1790609668, + "duration_ms": 56 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-379c-7200-8660-4db3bd1ee09c", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609668, + "completed_at": 1790609668, + "duration_ms": 90 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-379c-7200-8660-4db3bd1ee09c", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609668, + "completed_at": 1790609668, + "duration_ms": 394 + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-379c-7200-8660-4db3bd1ee09c", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609672, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-379c-7200-8660-4db3bd1ee09c", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609672, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-379c-7200-8660-4db3bd1ee09c", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609672, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-379c-7200-8660-4db3bd1ee09c", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609672, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-379c-7200-8660-4db3bd1ee09c", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609672, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-379c-7200-8660-4db3bd1ee09c", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609672, + "completed_at": 1790609672, + "duration_ms": 77 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-379c-7200-8660-4db3bd1ee09c", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609672, + "completed_at": 1790609672, + "duration_ms": 165 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-379c-7200-8660-4db3bd1ee09c", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609672, + "completed_at": 1790609672, + "duration_ms": 91 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-379c-7200-8660-4db3bd1ee09c", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609672, + "completed_at": 1790609672, + "duration_ms": 72 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-379c-7200-8660-4db3bd1ee09c", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609672, + "completed_at": 1790609672, + "duration_ms": 262 + } + ] + }, + { + "label": "DM", + "seq": 4, + "nonce": "PEERTABLE_PRODUCT_CODEX_DM_56b1130f-6749-4625-b6d0-017490cc5205", + "from": "probe", + "audience": "bell", + "raw_equal": true, + "body_sha256": "a4365541a766f32545ee94a671c13052b763c8bd22a848bc1c75e571c481f3fa", + "queue_body_sha256": "a4365541a766f32545ee94a671c13052b763c8bd22a848bc1c75e571c481f3fa", + "unicode_codepoints": 89, + "utf16_code_units": 90, + "utf8_bytes": 134, + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CODEX_DM_56b1130f-6749-4625-b6d0-017490cc5205 です。", + "queued_submission_id": "01a0e8a7-4f27-7d41-b190-5aeece3c852f", + "accepted_at": "2026-09-28T15:34:34.030Z", + "receipt": { + "state": "delivered", + "reason": "codex_queue_accepted", + "at": "2026-09-28T15:34:34.033Z", + "queued_submission_id": "01a0e8a7-4f27-7d41-b190-5aeece3c852f", + "accepted_at": "2026-09-28T15:34:34.030Z", + "receipt_revision": 1 + }, + "reply": { + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-5eb9-74c2-b55c-05d42d77a0e9", + "text": "配送確認の符号は `PEERTABLE_PRODUCT_CODEX_DM_56b1130f-6749-4625-b6d0-017490cc5205` です。送信者は `probe`、宛先は `bell`、seqは `4` です。受信を維持します。" + }, + "same_cid_new_turn": true, + "native_hooks": [ + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-5eb9-74c2-b55c-05d42d77a0e9", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609678, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-5eb9-74c2-b55c-05d42d77a0e9", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609678, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-5eb9-74c2-b55c-05d42d77a0e9", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609678, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-5eb9-74c2-b55c-05d42d77a0e9", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609678, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-5eb9-74c2-b55c-05d42d77a0e9", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609678, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-5eb9-74c2-b55c-05d42d77a0e9", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609678, + "completed_at": 1790609678, + "duration_ms": 77 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-5eb9-74c2-b55c-05d42d77a0e9", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609678, + "completed_at": 1790609678, + "duration_ms": 187 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-5eb9-74c2-b55c-05d42d77a0e9", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609678, + "completed_at": 1790609678, + "duration_ms": 53 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-5eb9-74c2-b55c-05d42d77a0e9", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609678, + "completed_at": 1790609678, + "duration_ms": 95 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-5eb9-74c2-b55c-05d42d77a0e9", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609678, + "completed_at": 1790609678, + "duration_ms": 404 + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-5eb9-74c2-b55c-05d42d77a0e9", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609682, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-5eb9-74c2-b55c-05d42d77a0e9", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609682, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-5eb9-74c2-b55c-05d42d77a0e9", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609682, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-5eb9-74c2-b55c-05d42d77a0e9", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609682, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-5eb9-74c2-b55c-05d42d77a0e9", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609682, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-5eb9-74c2-b55c-05d42d77a0e9", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609682, + "completed_at": 1790609683, + "duration_ms": 74 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-5eb9-74c2-b55c-05d42d77a0e9", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609682, + "completed_at": 1790609683, + "duration_ms": 169 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-5eb9-74c2-b55c-05d42d77a0e9", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609682, + "completed_at": 1790609683, + "duration_ms": 95 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-5eb9-74c2-b55c-05d42d77a0e9", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609682, + "completed_at": 1790609683, + "duration_ms": 73 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-5eb9-74c2-b55c-05d42d77a0e9", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609682, + "completed_at": 1790609683, + "duration_ms": 265 + } + ] + }, + { + "label": "MULTI", + "seq": 5, + "nonce": "PEERTABLE_PRODUCT_CODEX_MULTI_a483c9ca-9cac-41d6-8a8f-bd92679158ae", + "from": "probe", + "audience": [ + "bell", + "rui" + ], + "raw_equal": true, + "body_sha256": "97789fd66eabf96e10a0a013d9135d504ef67de133f11d11a2e3178ebca41f67", + "queue_body_sha256": "97789fd66eabf96e10a0a013d9135d504ef67de133f11d11a2e3178ebca41f67", + "unicode_codepoints": 92, + "utf16_code_units": 93, + "utf8_bytes": 137, + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CODEX_MULTI_a483c9ca-9cac-41d6-8a8f-bd92679158ae です。", + "queued_submission_id": "01a0e8a7-79d9-7681-a026-fe9b4b384094", + "accepted_at": "2026-09-28T15:34:44.959Z", + "receipt": { + "state": "delivered", + "reason": "codex_queue_accepted", + "at": "2026-09-28T15:34:44.963Z", + "queued_submission_id": "01a0e8a7-79d9-7681-a026-fe9b4b384094", + "accepted_at": "2026-09-28T15:34:44.959Z", + "receipt_revision": 1 + }, + "reply": { + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-85ce-7fb3-8a9e-e36245c6b024", + "text": "配送確認の符号は `PEERTABLE_PRODUCT_CODEX_MULTI_a483c9ca-9cac-41d6-8a8f-bd92679158ae` です。送信者は `probe`、宛先は `[\"bell\",\"rui\"]`、seqは `5` です。受信を維持します。" + }, + "same_cid_new_turn": true, + "native_hooks": [ + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-85ce-7fb3-8a9e-e36245c6b024", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609688, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-85ce-7fb3-8a9e-e36245c6b024", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609688, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-85ce-7fb3-8a9e-e36245c6b024", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609688, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-85ce-7fb3-8a9e-e36245c6b024", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609688, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-85ce-7fb3-8a9e-e36245c6b024", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609688, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-85ce-7fb3-8a9e-e36245c6b024", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609688, + "completed_at": 1790609688, + "duration_ms": 75 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-85ce-7fb3-8a9e-e36245c6b024", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609688, + "completed_at": 1790609688, + "duration_ms": 184 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-85ce-7fb3-8a9e-e36245c6b024", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609688, + "completed_at": 1790609688, + "duration_ms": 51 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-85ce-7fb3-8a9e-e36245c6b024", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609688, + "completed_at": 1790609688, + "duration_ms": 91 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-85ce-7fb3-8a9e-e36245c6b024", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609688, + "completed_at": 1790609688, + "duration_ms": 392 + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-85ce-7fb3-8a9e-e36245c6b024", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609691, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-85ce-7fb3-8a9e-e36245c6b024", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609691, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-85ce-7fb3-8a9e-e36245c6b024", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609691, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-85ce-7fb3-8a9e-e36245c6b024", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609691, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-85ce-7fb3-8a9e-e36245c6b024", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609691, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-85ce-7fb3-8a9e-e36245c6b024", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609691, + "completed_at": 1790609691, + "duration_ms": 80 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-85ce-7fb3-8a9e-e36245c6b024", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609691, + "completed_at": 1790609691, + "duration_ms": 163 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-85ce-7fb3-8a9e-e36245c6b024", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609691, + "completed_at": 1790609691, + "duration_ms": 97 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-85ce-7fb3-8a9e-e36245c6b024", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609691, + "completed_at": 1790609691, + "duration_ms": 71 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-85ce-7fb3-8a9e-e36245c6b024", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609691, + "completed_at": 1790609691, + "duration_ms": 272 + } + ] + }, + { + "label": "LONG", + "seq": 6, + "nonce": "PEERTABLE_PRODUCT_CODEX_LONG_0e217815-ce2b-4885-9d42-4c84615aad99", + "from": "probe", + "audience": "all", + "raw_equal": true, + "body_sha256": "d9484d25bd94eb27ac07b14e79aa030f7def17df6ff595cd89d73bf2ab883a7b", + "queue_body_sha256": "d9484d25bd94eb27ac07b14e79aa030f7def17df6ff595cd89d73bf2ab883a7b", + "unicode_codepoints": 36096, + "utf16_code_units": 40097, + "utf8_bytes": 104151, + "body": null, + "queued_submission_id": "01a0e8a7-9ba5-75b1-80e5-70603d3b2fc3", + "accepted_at": "2026-09-28T15:34:53.616Z", + "receipt": { + "state": "delivered", + "reason": "codex_queue_accepted", + "at": "2026-09-28T15:34:53.630Z", + "queued_submission_id": "01a0e8a7-9ba5-75b1-80e5-70603d3b2fc3", + "accepted_at": "2026-09-28T15:34:53.616Z", + "receipt_revision": 1 + }, + "reply": { + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-ace6-7e53-bc78-6b9cec87bff9", + "text": "配送確認の符号は `PEERTABLE_PRODUCT_CODEX_LONG_0e217815-ce2b-4885-9d42-4c84615aad99` です。送信者は `probe`、宛先は `all`、seqは `6` です。受信を維持します。" + }, + "same_cid_new_turn": true, + "native_hooks": [ + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-ace6-7e53-bc78-6b9cec87bff9", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609698, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-ace6-7e53-bc78-6b9cec87bff9", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609698, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-ace6-7e53-bc78-6b9cec87bff9", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609698, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-ace6-7e53-bc78-6b9cec87bff9", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609698, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-ace6-7e53-bc78-6b9cec87bff9", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609698, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-ace6-7e53-bc78-6b9cec87bff9", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609698, + "completed_at": 1790609698, + "duration_ms": 91 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-ace6-7e53-bc78-6b9cec87bff9", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609698, + "completed_at": 1790609698, + "duration_ms": 201 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-ace6-7e53-bc78-6b9cec87bff9", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609698, + "completed_at": 1790609698, + "duration_ms": 67 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-ace6-7e53-bc78-6b9cec87bff9", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609698, + "completed_at": 1790609698, + "duration_ms": 97 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "turn": "01a0e8a7-ace6-7e53-bc78-6b9cec87bff9", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609698, + "completed_at": 1790609698, + "duration_ms": 400 + } + ] + } + ], + "long_stop_supplement": { + "schema": "peertable.parent-product-native-snapshot-projection.v1", + "source_status": "固定snapshotのnative観測。525件の最終製品受入へ算入しない。長文本文は原本のhash投影。", + "platform": "linux", + "surface": "codex-cli-app-server", + "room": "parent-codex-2710fa40-0cd8-42ab-999e-c850c4acb393", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "endpoint": "a6a7b343-37a9-4948-a2be-cb3dee96ea9a", + "runtime_digest": "3fc3484d597bd326539f4b102e346c8386562d35c2cdbfe7e5dbce0b46863161", + "snapshot": { + "source_path": "/Users/kite/.codex/worktrees/aiterm-unified-delivery/peertable", + "source_digest_before": "3fc3484d597bd326539f4b102e346c8386562d35c2cdbfe7e5dbce0b46863161", + "source_digest_after": "3fc3484d597bd326539f4b102e346c8386562d35c2cdbfe7e5dbce0b46863161", + "snapshot_runtime_digest": "3fc3484d597bd326539f4b102e346c8386562d35c2cdbfe7e5dbce0b46863161", + "source_stable": true, + "source_commit": "7642b697cabb95c3fb9ca1b4cb323eae02973445", + "package_version": "0.8.62", + "room_utf8_decoder_present": true + }, + "cli_version": "codex-cli 0.157.1", + "node_version": "v24.20.0", + "idle_checks": [ + { + "label": "beforeLONGAfterJoin", + "at": "2026-09-28T15:43:50.821Z", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "status": { + "type": "idle" + } + }, + { + "label": "beforeLONG", + "at": "2026-09-28T15:43:52.324Z", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "status": { + "type": "idle" + } + }, + { + "label": "afterLONG", + "at": "2026-09-28T15:44:08.692Z", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "status": { + "type": "idle" + } + } + ], + "checks": [ + { + "label": "LONG", + "seq": 3, + "nonce": "PEERTABLE_PRODUCT_CODEX_LONG_a35dc837-2801-4129-972e-baadacce5cdf", + "from": "probe", + "audience": "all", + "raw_equal": true, + "body_sha256": "dc4b3793287c7b0d38715511fccb1806b8da23c9cc5fb350677018dc6bb40ac5", + "queue_body_sha256": "dc4b3793287c7b0d38715511fccb1806b8da23c9cc5fb350677018dc6bb40ac5", + "unicode_codepoints": 36096, + "utf16_code_units": 40097, + "utf8_bytes": 104151, + "body": null, + "queued_submission_id": "01a0e8af-d48b-7033-8e61-15c890449779", + "accepted_at": "2026-09-28T15:43:52.469Z", + "receipt": { + "state": "delivered", + "reason": "codex_queue_accepted", + "at": "2026-09-28T15:43:52.483Z", + "queued_submission_id": "01a0e8af-d48b-7033-8e61-15c890449779", + "accepted_at": "2026-09-28T15:43:52.469Z", + "receipt_revision": 1 + }, + "reply": { + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "text": "長文通知の配送本文を回収しました。\n\n- 配送確認符号:`PEERTABLE_PRODUCT_CODEX_LONG_a35dc837-2801-4129-972e-baadacce5cdf`\n- 送信者:`probe`\n- 宛先:`all`\n- seq:`3`\n\n受信登録は維持しています。" + }, + "same_cid_new_turn": true, + "native_hooks": [ + { + "method": "hook/started", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790610236, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790610236, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790610236, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790610236, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790610236, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/completed", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790610236, + "completed_at": 1790610236, + "duration_ms": 84 + }, + { + "method": "hook/completed", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790610236, + "completed_at": 1790610236, + "duration_ms": 205 + }, + { + "method": "hook/completed", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790610236, + "completed_at": 1790610236, + "duration_ms": 55 + }, + { + "method": "hook/completed", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790610236, + "completed_at": 1790610236, + "duration_ms": 94 + }, + { + "method": "hook/completed", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790610236, + "completed_at": 1790610236, + "duration_ms": 394 + }, + { + "method": "hook/started", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "preToolUse", + "execution_mode": "sync", + "status": "running", + "started_at": 1790610242, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/completed", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "preToolUse", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790610242, + "completed_at": 1790610242, + "duration_ms": 37 + }, + { + "method": "hook/started", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "postToolUse", + "execution_mode": "sync", + "status": "running", + "started_at": 1790610242, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "postToolUse", + "execution_mode": "sync", + "status": "running", + "started_at": 1790610242, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "postToolUse", + "execution_mode": "sync", + "status": "running", + "started_at": 1790610242, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "postToolUse", + "execution_mode": "sync", + "status": "running", + "started_at": 1790610242, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/completed", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "postToolUse", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790610242, + "completed_at": 1790610242, + "duration_ms": 90 + }, + { + "method": "hook/completed", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "postToolUse", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790610242, + "completed_at": 1790610242, + "duration_ms": 160 + }, + { + "method": "hook/completed", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "postToolUse", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790610242, + "completed_at": 1790610242, + "duration_ms": 74 + }, + { + "method": "hook/completed", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "postToolUse", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790610242, + "completed_at": 1790610242, + "duration_ms": 285 + }, + { + "method": "hook/started", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790610246, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790610246, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790610246, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790610246, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790610246, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/completed", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790610246, + "completed_at": 1790610246, + "duration_ms": 87 + }, + { + "method": "hook/completed", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790610246, + "completed_at": 1790610246, + "duration_ms": 166 + }, + { + "method": "hook/completed", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790610246, + "completed_at": 1790610246, + "duration_ms": 94 + }, + { + "method": "hook/completed", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790610246, + "completed_at": 1790610246, + "duration_ms": 70 + }, + { + "method": "hook/completed", + "thread": "01a0e8ae-d32c-73b3-8e2e-dbb8573a43a5", + "turn": "01a0e8af-e2c4-7ca1-995b-6dd986bfb042", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790610246, + "completed_at": 1790610247, + "duration_ms": 289 + } + ] + } + ] + }, + "cleanup": { + "thread": "01a0e8a6-75d7-76b1-aa8c-ba7b68d7bcac", + "thread_archived": true, + "config_hash_before": "f5280b41b49218e7da42a81adf0d390001e751bf3bd7500329f066772745b8bc", + "config_hash_after": "42729c88d030a9ace3eb7a4c324938e141f7d7a62cc41cfd5eb0101f4f144c38", + "other_config_preserved": true, + "other_hooks_preserved": true, + "endpoints": [ + { + "id": "aeee7de8-1b0d-4127-b450-178691c000b1", + "runtime": "stopped", + "watcher": null, + "waiter": null, + "caller_alive": false + } + ], + "config_original_hash_method": "JSON.stringify raw key order", + "config_canonical_hash_before": "ebdd9325ff6dad398708176e661039b8f1c281613d63c45825264d7abedc63f4", + "config_canonical_hash_after": "ebdd9325ff6dad398708176e661039b8f1c281613d63c45825264d7abedc63f4", + "config_semantic_diff": [] + }, + "supplement_cleanup": { + "config_preserved": true, + "hooks_preserved": true, + "processes_stopped": true + } +} diff --git a/rag/parent-delivery/product-snapshot-codex-win32.json b/rag/parent-delivery/product-snapshot-codex-win32.json new file mode 100644 index 00000000..2e7deef7 --- /dev/null +++ b/rag/parent-delivery/product-snapshot-codex-win32.json @@ -0,0 +1,1361 @@ +{ + "schema": "peertable.parent-product-native-snapshot-projection.v1", + "source_status": "固定snapshotのnative観測。525件の最終製品受入へ算入しない。長文本文は原本のhash投影。", + "platform": "win32", + "surface": "codex-cli-app-server", + "room": "parent-codex-830a17b1-d636-47fc-80b4-ca96403dbfb4", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "endpoint": "a5a380d1-949d-4f10-bd20-c31021e1dc03", + "runtime_digest": "3fc3484d597bd326539f4b102e346c8386562d35c2cdbfe7e5dbce0b46863161", + "snapshot": { + "source_path": "/Users/kite/.codex/worktrees/aiterm-unified-delivery/peertable", + "source_digest_before": "3fc3484d597bd326539f4b102e346c8386562d35c2cdbfe7e5dbce0b46863161", + "source_digest_after": "3fc3484d597bd326539f4b102e346c8386562d35c2cdbfe7e5dbce0b46863161", + "snapshot_runtime_digest": "3fc3484d597bd326539f4b102e346c8386562d35c2cdbfe7e5dbce0b46863161", + "source_stable": true, + "source_commit": "7642b697cabb95c3fb9ca1b4cb323eae02973445", + "package_version": "0.8.62", + "room_utf8_decoder_present": true + }, + "cli_version": "codex-cli 0.157.1", + "node_version": "v24.19.0", + "idle_checks": [ + { + "label": "beforeALL", + "at": "2026-09-28T15:35:53.793Z", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "status": { + "type": "idle" + } + }, + { + "label": "beforeALL", + "at": "2026-09-28T15:35:55.297Z", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "status": { + "type": "idle" + } + }, + { + "label": "beforeDM", + "at": "2026-09-28T15:36:18.652Z", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "status": { + "type": "idle" + } + }, + { + "label": "beforeMULTI", + "at": "2026-09-28T15:36:45.855Z", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "status": { + "type": "idle" + } + }, + { + "label": "beforeLONG", + "at": "2026-09-28T15:37:10.156Z", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "status": { + "type": "idle" + } + } + ], + "checks": [ + { + "label": "ALL", + "seq": 3, + "nonce": "PEERTABLE_PRODUCT_CODEX_ALL_68d0d09d-c981-4409-9473-121e872f7cdd", + "from": "probe", + "audience": "all", + "raw_equal": true, + "body_sha256": "c46175f3ce74c1b18f30cdf0b810673bd52a887bb8290afa261c833be050c4d7", + "queue_body_sha256": "c46175f3ce74c1b18f30cdf0b810673bd52a887bb8290afa261c833be050c4d7", + "unicode_codepoints": 90, + "utf16_code_units": 91, + "utf8_bytes": 135, + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CODEX_ALL_68d0d09d-c981-4409-9473-121e872f7cdd です。", + "queued_submission_id": "01a0e8a8-8e95-7c02-97eb-5bb0fb70832d", + "accepted_at": "2026-09-28T15:35:55.802Z", + "receipt": { + "state": "delivered", + "reason": "codex_queue_accepted", + "at": "2026-09-28T15:35:55.826Z", + "queued_submission_id": "01a0e8a8-8e95-7c02-97eb-5bb0fb70832d", + "accepted_at": "2026-09-28T15:35:55.802Z", + "receipt_revision": 1 + }, + "reply": { + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-a233-7df1-9f2c-d6ebf57a0752", + "text": "配送確認の符号:`PEERTABLE_PRODUCT_CODEX_ALL_68d0d09d-c981-4409-9473-121e872f7cdd`\n\n- 送信者:`probe`\n- 宛先:`all`\n- seq:`3`" + }, + "same_cid_new_turn": true, + "native_hooks": [ + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-a233-7df1-9f2c-d6ebf57a0752", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609760, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-a233-7df1-9f2c-d6ebf57a0752", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609760, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-a233-7df1-9f2c-d6ebf57a0752", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609760, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-a233-7df1-9f2c-d6ebf57a0752", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609760, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-a233-7df1-9f2c-d6ebf57a0752", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609760, + "completed_at": 1790609761, + "duration_ms": 233 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-a233-7df1-9f2c-d6ebf57a0752", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609760, + "completed_at": 1790609761, + "duration_ms": 790 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-a233-7df1-9f2c-d6ebf57a0752", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609760, + "completed_at": 1790609766, + "duration_ms": 5726 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-a233-7df1-9f2c-d6ebf57a0752", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609760, + "completed_at": 1790609762, + "duration_ms": 1088 + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-a233-7df1-9f2c-d6ebf57a0752", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609773, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-a233-7df1-9f2c-d6ebf57a0752", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609773, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-a233-7df1-9f2c-d6ebf57a0752", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609773, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-a233-7df1-9f2c-d6ebf57a0752", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609773, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-a233-7df1-9f2c-d6ebf57a0752", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609773, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-a233-7df1-9f2c-d6ebf57a0752", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609773, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-a233-7df1-9f2c-d6ebf57a0752", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609773, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-a233-7df1-9f2c-d6ebf57a0752", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609773, + "completed_at": 1790609774, + "duration_ms": 249 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-a233-7df1-9f2c-d6ebf57a0752", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609773, + "completed_at": 1790609774, + "duration_ms": 296 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-a233-7df1-9f2c-d6ebf57a0752", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609773, + "completed_at": 1790609774, + "duration_ms": 263 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-a233-7df1-9f2c-d6ebf57a0752", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609773, + "completed_at": 1790609774, + "duration_ms": 652 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-a233-7df1-9f2c-d6ebf57a0752", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609773, + "completed_at": 1790609774, + "duration_ms": 727 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-a233-7df1-9f2c-d6ebf57a0752", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609773, + "completed_at": 1790609774, + "duration_ms": 776 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-a233-7df1-9f2c-d6ebf57a0752", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609773, + "completed_at": 1790609777, + "duration_ms": 3080 + } + ] + }, + { + "label": "DM", + "seq": 4, + "nonce": "PEERTABLE_PRODUCT_CODEX_DM_ccf3ef0f-84c1-4a9b-9f3b-fe68ce1a33b1", + "from": "probe", + "audience": "bell", + "raw_equal": true, + "body_sha256": "5d8a2e5c2e65382feb73df55edebaeadecb1e824a20dc4c9127aa913716c5824", + "queue_body_sha256": "5d8a2e5c2e65382feb73df55edebaeadecb1e824a20dc4c9127aa913716c5824", + "unicode_codepoints": 89, + "utf16_code_units": 90, + "utf8_bytes": 134, + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CODEX_DM_ccf3ef0f-84c1-4a9b-9f3b-fe68ce1a33b1 です。", + "queued_submission_id": "01a0e8a8-ea20-7c60-a5af-b7813da88fb5", + "accepted_at": "2026-09-28T15:36:19.238Z", + "receipt": { + "state": "delivered", + "reason": "codex_queue_accepted", + "at": "2026-09-28T15:36:19.260Z", + "queued_submission_id": "01a0e8a8-ea20-7c60-a5af-b7813da88fb5", + "accepted_at": "2026-09-28T15:36:19.238Z", + "receipt_revision": 1 + }, + "reply": { + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-f04b-76e2-a28b-76ef63e52797", + "text": "配送確認の符号:`PEERTABLE_PRODUCT_CODEX_DM_ccf3ef0f-84c1-4a9b-9f3b-fe68ce1a33b1`\n\n- 送信者:`probe`\n- 宛先:`bell`\n- seq:`4`" + }, + "same_cid_new_turn": true, + "native_hooks": [ + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-f04b-76e2-a28b-76ef63e52797", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609780, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-f04b-76e2-a28b-76ef63e52797", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609780, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-f04b-76e2-a28b-76ef63e52797", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609780, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-f04b-76e2-a28b-76ef63e52797", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609780, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-f04b-76e2-a28b-76ef63e52797", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609780, + "completed_at": 1790609781, + "duration_ms": 240 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-f04b-76e2-a28b-76ef63e52797", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609780, + "completed_at": 1790609781, + "duration_ms": 797 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-f04b-76e2-a28b-76ef63e52797", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609780, + "completed_at": 1790609786, + "duration_ms": 5710 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-f04b-76e2-a28b-76ef63e52797", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609780, + "completed_at": 1790609781, + "duration_ms": 1063 + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-f04b-76e2-a28b-76ef63e52797", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609801, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-f04b-76e2-a28b-76ef63e52797", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609801, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-f04b-76e2-a28b-76ef63e52797", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609801, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-f04b-76e2-a28b-76ef63e52797", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609801, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-f04b-76e2-a28b-76ef63e52797", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609801, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-f04b-76e2-a28b-76ef63e52797", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609801, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-f04b-76e2-a28b-76ef63e52797", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609801, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-f04b-76e2-a28b-76ef63e52797", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609801, + "completed_at": 1790609801, + "duration_ms": 260 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-f04b-76e2-a28b-76ef63e52797", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609801, + "completed_at": 1790609801, + "duration_ms": 304 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-f04b-76e2-a28b-76ef63e52797", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609801, + "completed_at": 1790609801, + "duration_ms": 253 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-f04b-76e2-a28b-76ef63e52797", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609801, + "completed_at": 1790609801, + "duration_ms": 629 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-f04b-76e2-a28b-76ef63e52797", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609801, + "completed_at": 1790609801, + "duration_ms": 721 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-f04b-76e2-a28b-76ef63e52797", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609801, + "completed_at": 1790609801, + "duration_ms": 738 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a8-f04b-76e2-a28b-76ef63e52797", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609801, + "completed_at": 1790609804, + "duration_ms": 3043 + } + ] + }, + { + "label": "MULTI", + "seq": 5, + "nonce": "PEERTABLE_PRODUCT_CODEX_MULTI_f9512890-fa91-4bc4-8fa1-444318b07104", + "from": "probe", + "audience": [ + "bell", + "rui" + ], + "raw_equal": true, + "body_sha256": "f1186392a4dcc22fcebc6a77c5ef41e48b9513f976a9ef832cf9440ccf06b718", + "queue_body_sha256": "f1186392a4dcc22fcebc6a77c5ef41e48b9513f976a9ef832cf9440ccf06b718", + "unicode_codepoints": 92, + "utf16_code_units": 93, + "utf8_bytes": 137, + "body": "日本語の観測\n「引用」😀\n配送確認の符号は PEERTABLE_PRODUCT_CODEX_MULTI_f9512890-fa91-4bc4-8fa1-444318b07104 です。", + "queued_submission_id": "01a0e8a9-5425-7251-859d-6b171b81daeb", + "accepted_at": "2026-09-28T15:36:46.378Z", + "receipt": { + "state": "delivered", + "reason": "codex_queue_accepted", + "at": "2026-09-28T15:36:46.400Z", + "queued_submission_id": "01a0e8a9-5425-7251-859d-6b171b81daeb", + "accepted_at": "2026-09-28T15:36:46.378Z", + "receipt_revision": 1 + }, + "reply": { + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-6580-72f3-bb48-b56852f6f5b6", + "text": "配送確認の符号:`PEERTABLE_PRODUCT_CODEX_MULTI_f9512890-fa91-4bc4-8fa1-444318b07104`\n\n- 送信者:`probe`\n- 宛先:`[\"bell\",\"rui\"]`\n- seq:`5`" + }, + "same_cid_new_turn": true, + "native_hooks": [ + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-6580-72f3-bb48-b56852f6f5b6", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609810, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-6580-72f3-bb48-b56852f6f5b6", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609810, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-6580-72f3-bb48-b56852f6f5b6", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609810, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-6580-72f3-bb48-b56852f6f5b6", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609810, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-6580-72f3-bb48-b56852f6f5b6", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609810, + "completed_at": 1790609811, + "duration_ms": 239 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-6580-72f3-bb48-b56852f6f5b6", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609810, + "completed_at": 1790609812, + "duration_ms": 1996 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-6580-72f3-bb48-b56852f6f5b6", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609810, + "completed_at": 1790609816, + "duration_ms": 5850 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-6580-72f3-bb48-b56852f6f5b6", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609810, + "completed_at": 1790609812, + "duration_ms": 1095 + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-6580-72f3-bb48-b56852f6f5b6", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609822, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-6580-72f3-bb48-b56852f6f5b6", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609822, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-6580-72f3-bb48-b56852f6f5b6", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609822, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-6580-72f3-bb48-b56852f6f5b6", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609822, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-6580-72f3-bb48-b56852f6f5b6", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609822, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-6580-72f3-bb48-b56852f6f5b6", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609822, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-6580-72f3-bb48-b56852f6f5b6", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609822, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-6580-72f3-bb48-b56852f6f5b6", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609822, + "completed_at": 1790609822, + "duration_ms": 285 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-6580-72f3-bb48-b56852f6f5b6", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609822, + "completed_at": 1790609822, + "duration_ms": 338 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-6580-72f3-bb48-b56852f6f5b6", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609822, + "completed_at": 1790609828, + "duration_ms": 6238 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-6580-72f3-bb48-b56852f6f5b6", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609822, + "completed_at": 1790609823, + "duration_ms": 677 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-6580-72f3-bb48-b56852f6f5b6", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609822, + "completed_at": 1790609823, + "duration_ms": 759 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-6580-72f3-bb48-b56852f6f5b6", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609822, + "completed_at": 1790609823, + "duration_ms": 815 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-6580-72f3-bb48-b56852f6f5b6", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609822, + "completed_at": 1790609825, + "duration_ms": 3221 + } + ] + }, + { + "label": "LONG", + "seq": 6, + "nonce": "PEERTABLE_PRODUCT_CODEX_LONG_0a4f1a95-4970-45a6-a78b-8f2e2db6bdad", + "from": "probe", + "audience": "all", + "raw_equal": true, + "body_sha256": "386708a1735f11473f14cda3b4e690c47ec84a9c96ed4da8d0b49a8c8b81c7ce", + "queue_body_sha256": "386708a1735f11473f14cda3b4e690c47ec84a9c96ed4da8d0b49a8c8b81c7ce", + "unicode_codepoints": 36096, + "utf16_code_units": 40097, + "utf8_bytes": 104151, + "body": null, + "queued_submission_id": "01a0e8a9-b31a-7410-ba92-7896b6c5a8c7", + "accepted_at": "2026-09-28T15:37:10.691Z", + "receipt": { + "state": "delivered", + "reason": "codex_queue_accepted", + "at": "2026-09-28T15:37:10.724Z", + "queued_submission_id": "01a0e8a9-b31a-7410-ba92-7896b6c5a8c7", + "accepted_at": "2026-09-28T15:37:10.691Z", + "receipt_revision": 1 + }, + "reply": { + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-b39f-7973-960a-7878a090cb7a", + "text": "配送確認の符号:`PEERTABLE_PRODUCT_CODEX_LONG_0a4f1a95-4970-45a6-a78b-8f2e2db6bdad`\n\n- 送信者:`probe`\n- 宛先:`all`\n- seq:`6`" + }, + "same_cid_new_turn": true, + "native_hooks": [ + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-b39f-7973-960a-7878a090cb7a", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609830, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-b39f-7973-960a-7878a090cb7a", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609830, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-b39f-7973-960a-7878a090cb7a", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609830, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-b39f-7973-960a-7878a090cb7a", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790609830, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-b39f-7973-960a-7878a090cb7a", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609830, + "completed_at": 1790609831, + "duration_ms": 241 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-b39f-7973-960a-7878a090cb7a", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609830, + "completed_at": 1790609832, + "duration_ms": 1981 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-b39f-7973-960a-7878a090cb7a", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609830, + "completed_at": 1790609837, + "duration_ms": 6525 + }, + { + "method": "hook/completed", + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "turn": "01a0e8a9-b39f-7973-960a-7878a090cb7a", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790609830, + "completed_at": 1790609832, + "duration_ms": 1145 + } + ] + } + ], + "long_stop_supplement": { + "schema": "peertable.parent-product-native-snapshot-projection.v1", + "source_status": "固定snapshotのnative観測。525件の最終製品受入へ算入しない。長文本文は原本のhash投影。", + "platform": "win32", + "surface": "codex-cli-app-server", + "room": "parent-codex-3de366eb-1d3e-48d7-9435-bae9730cd37f", + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "endpoint": "80b8bb6b-fe3a-449f-84f6-53ad9b17010f", + "runtime_digest": "3fc3484d597bd326539f4b102e346c8386562d35c2cdbfe7e5dbce0b46863161", + "snapshot": { + "source_path": "/Users/kite/.codex/worktrees/aiterm-unified-delivery/peertable", + "source_digest_before": "3fc3484d597bd326539f4b102e346c8386562d35c2cdbfe7e5dbce0b46863161", + "source_digest_after": "3fc3484d597bd326539f4b102e346c8386562d35c2cdbfe7e5dbce0b46863161", + "snapshot_runtime_digest": "3fc3484d597bd326539f4b102e346c8386562d35c2cdbfe7e5dbce0b46863161", + "source_stable": true, + "source_commit": "7642b697cabb95c3fb9ca1b4cb323eae02973445", + "package_version": "0.8.62", + "room_utf8_decoder_present": true + }, + "cli_version": "codex-cli 0.157.1", + "node_version": "v24.19.0", + "idle_checks": [ + { + "label": "beforeLONGAfterJoin", + "at": "2026-09-28T15:44:13.692Z", + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "status": { + "type": "idle" + } + }, + { + "label": "beforeLONG", + "at": "2026-09-28T15:44:15.209Z", + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "status": { + "type": "idle" + } + }, + { + "label": "afterLONG", + "at": "2026-09-28T15:44:38.714Z", + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "status": { + "type": "idle" + } + } + ], + "checks": [ + { + "label": "LONG", + "seq": 3, + "nonce": "PEERTABLE_PRODUCT_CODEX_LONG_d4b4417d-f529-4840-acd4-fe52311ae606", + "from": "probe", + "audience": "all", + "raw_equal": true, + "body_sha256": "cbf8bc9d66bf1ccdb2c44f7dc1becd3f940be3c43a709b7b3db50b114acc50f6", + "queue_body_sha256": "cbf8bc9d66bf1ccdb2c44f7dc1becd3f940be3c43a709b7b3db50b114acc50f6", + "unicode_codepoints": 36096, + "utf16_code_units": 40097, + "utf8_bytes": 104151, + "body": null, + "queued_submission_id": "01a0e8b0-3075-7812-88d9-fa50e8f52b5d", + "accepted_at": "2026-09-28T15:44:15.998Z", + "receipt": { + "state": "delivered", + "reason": "codex_queue_accepted", + "at": "2026-09-28T15:44:16.032Z", + "queued_submission_id": "01a0e8b0-3075-7812-88d9-fa50e8f52b5d", + "accepted_at": "2026-09-28T15:44:15.998Z", + "receipt_revision": 1 + }, + "reply": { + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "turn": "01a0e8b0-37d6-7a61-a1a3-73aa452bac80", + "text": "room観測データを受信しました。\n\n- 配送確認符号: `PEERTABLE_PRODUCT_CODEX_LONG_d4b4417d-f529-4840-acd4-fe52311ae606`\n- 送信者: `probe`\n- 宛先: `all`\n- seq: `3`" + }, + "same_cid_new_turn": true, + "native_hooks": [ + { + "method": "hook/started", + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "turn": "01a0e8b0-37d6-7a61-a1a3-73aa452bac80", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790610258, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "turn": "01a0e8b0-37d6-7a61-a1a3-73aa452bac80", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790610258, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "turn": "01a0e8b0-37d6-7a61-a1a3-73aa452bac80", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790610258, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "turn": "01a0e8b0-37d6-7a61-a1a3-73aa452bac80", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "running", + "started_at": 1790610258, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/completed", + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "turn": "01a0e8b0-37d6-7a61-a1a3-73aa452bac80", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790610258, + "completed_at": 1790610258, + "duration_ms": 253 + }, + { + "method": "hook/completed", + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "turn": "01a0e8b0-37d6-7a61-a1a3-73aa452bac80", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790610258, + "completed_at": 1790610258, + "duration_ms": 811 + }, + { + "method": "hook/completed", + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "turn": "01a0e8b0-37d6-7a61-a1a3-73aa452bac80", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790610258, + "completed_at": 1790610263, + "duration_ms": 5095 + }, + { + "method": "hook/completed", + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "turn": "01a0e8b0-37d6-7a61-a1a3-73aa452bac80", + "event_name": "userPromptSubmit", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790610258, + "completed_at": 1790610259, + "duration_ms": 1098 + }, + { + "method": "hook/started", + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "turn": "01a0e8b0-37d6-7a61-a1a3-73aa452bac80", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790610270, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "turn": "01a0e8b0-37d6-7a61-a1a3-73aa452bac80", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790610270, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "turn": "01a0e8b0-37d6-7a61-a1a3-73aa452bac80", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790610270, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "turn": "01a0e8b0-37d6-7a61-a1a3-73aa452bac80", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790610270, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "turn": "01a0e8b0-37d6-7a61-a1a3-73aa452bac80", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790610270, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "turn": "01a0e8b0-37d6-7a61-a1a3-73aa452bac80", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790610270, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/started", + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "turn": "01a0e8b0-37d6-7a61-a1a3-73aa452bac80", + "event_name": "stop", + "execution_mode": "sync", + "status": "running", + "started_at": 1790610270, + "completed_at": null, + "duration_ms": null + }, + { + "method": "hook/completed", + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "turn": "01a0e8b0-37d6-7a61-a1a3-73aa452bac80", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790610270, + "completed_at": 1790610270, + "duration_ms": 294 + }, + { + "method": "hook/completed", + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "turn": "01a0e8b0-37d6-7a61-a1a3-73aa452bac80", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790610270, + "completed_at": 1790610270, + "duration_ms": 331 + }, + { + "method": "hook/completed", + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "turn": "01a0e8b0-37d6-7a61-a1a3-73aa452bac80", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790610270, + "completed_at": 1790610277, + "duration_ms": 6508 + }, + { + "method": "hook/completed", + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "turn": "01a0e8b0-37d6-7a61-a1a3-73aa452bac80", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790610270, + "completed_at": 1790610271, + "duration_ms": 684 + }, + { + "method": "hook/completed", + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "turn": "01a0e8b0-37d6-7a61-a1a3-73aa452bac80", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790610270, + "completed_at": 1790610271, + "duration_ms": 774 + }, + { + "method": "hook/completed", + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "turn": "01a0e8b0-37d6-7a61-a1a3-73aa452bac80", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790610270, + "completed_at": 1790610271, + "duration_ms": 793 + }, + { + "method": "hook/completed", + "thread": "01a0e8ae-d9d2-70c2-bab2-2e9980fbb4dd", + "turn": "01a0e8b0-37d6-7a61-a1a3-73aa452bac80", + "event_name": "stop", + "execution_mode": "sync", + "status": "completed", + "started_at": 1790610270, + "completed_at": 1790610273, + "duration_ms": 3252 + } + ] + } + ] + }, + "cleanup": { + "thread": "01a0e8a6-8083-7192-98d9-fa9065e2648c", + "thread_archived": true, + "config_hash_before": "b33b77de617d2d7cc0b5a1aa88413c5cf7e9fc3968f4a19ed49e1556f24a41b8", + "config_hash_after": "b9356386f4e9db3dff434ff21a2f92c85872c224b4bafb5ed0eb3c23f6485d23", + "other_config_preserved": true, + "other_hooks_preserved": true, + "endpoints": [ + { + "id": "a5a380d1-949d-4f10-bd20-c31021e1dc03", + "runtime": "stopped", + "watcher": null, + "waiter": null, + "caller_alive": false + } + ], + "config_original_hash_method": "JSON.stringify raw key order", + "config_canonical_hash_before": "fe0297cbcf89a929327e7f5d3f3ea431e00d12dabad29671ea48b860b5970556", + "config_canonical_hash_after": "fe0297cbcf89a929327e7f5d3f3ea431e00d12dabad29671ea48b860b5970556", + "config_semantic_diff": [] + }, + "supplement_cleanup": { + "config_preserved": true, + "hooks_preserved": true, + "processes_stopped": true + } +} From 65910717039418be0346df30382cc05c644528c1 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:35:08 +0900 Subject: [PATCH 08/72] =?UTF-8?q?=E8=A6=AA=E9=85=8D=E9=80=81=E3=81=AE?= =?UTF-8?q?=E5=88=A9=E7=94=A8=E3=83=BB=E5=8F=97=E5=85=A5=E3=83=BB=E9=85=8D?= =?UTF-8?q?=E5=B8=83=E6=96=87=E6=9B=B8=E3=82=92=E6=8F=83=E3=81=88=E3=81=A6?= =?UTF-8?q?=E6=AC=A1=E7=89=88=E3=82=92=E6=BA=96=E5=82=99=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 現行設計へ親の公式受信口と状態・原文保持を統合し、導入・診断・終了・room反映の説明を揃える。 正式受入のsourceと版を固定するため0.8.63へ更新する。公開gateは未完了の実測を通さない。 --- README.ja.md | 4 ++-- README.md | 4 ++-- deploy/README.md | 6 ++++++ docs/00_overview.md | 4 ++++ docs/current-design.md | 26 ++++++++++++++++++-------- docs/plan_parent-native-delivery.md | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- skill/SKILL.md | 2 +- 9 files changed, 38 insertions(+), 18 deletions(-) diff --git a/README.ja.md b/README.ja.md index c0077548..08cb3f3c 100644 --- a/README.ja.md +++ b/README.ja.md @@ -99,11 +99,11 @@ setupの再実行も既存projectではresumeへ進み、room・議題・生存 teardownの既定は解散。席と所有する足場を撤去し、roomと過去ログ、Lattice storeを残す。`--purge`はroomと新設storeも削除する。既存設定・無関係な作業差分は保つ。停止またはroom操作に失敗した場合は再実行用の記録を残してエラーを返す。 -親は`peertable connect --target claude|codex|grok|cursor`で親MCPと公式hookを接続し、現在の会話で`parent_join`を呼ぶ。親宛DM・複数人宛・all全件を原文のまま受信する。Cursor/Grokは返却された完成済みnative背景tool入力を登録する。接続設定だけで着卓完了とせず、耳疎通と現在の受信継続を確認する。12組合せの製品実機受入は[進行中の計画](https://github.com/kitepon/peertable/blob/main/docs/plan_parent-native-delivery.md)で管理している。親の耳疎通、kickoffの引受確認、席設定変更の手順は[同梱スキル](skill/SKILL.md)にまとめている。 +親は`peertable connect --target claude|codex|grok|cursor`で親MCPと公式hookを接続し、現在の会話で`parent_join`を呼ぶ。親宛DM・複数人宛・all全件を原文のまま受信する。Cursor/Grokは返却された完成済みnative背景tool入力を登録する。接続設定だけで着卓完了とせず、耳疎通と現在の受信継続を確認する。親配送の現行契約と受入の入口は[現行設計](https://github.com/kitepon/peertable/blob/main/docs/current-design.md)を参照する。親の耳疎通、kickoffの引受確認、席設定変更の手順は[同梱スキル](skill/SKILL.md)にまとめている。 **roomがメンバーの唯一の台帳。** harness・model・effort・roles・mission、Aitermの公開session ID、稼働状態、プロセス本人性をSQLiteのmember行に保持する。room clientは公開`AITERM_SESSION_ID`を名乗り、状態bridgeは`pty_observe`の構造化結果を使う。PeertableはAitermの内部ファイル、socket、namespace、画面文言を解析しない。 -メンバーカードは名前・状態の丸・役割を表示し、詳細からmodel等を確認できる。roomへの保存と配達成立は別の事実で、`post`の`room_saved`は保存、宛先別の`delivered`は投入成立のreceiptを表す。`members`はserverが計算した実効状態とbridge healthを返す。送信後の成否不明は`failed`として保留し、本文を自動再送しない。Claudeも統合配達APIを使い、未知の承認や判定不能を成功へ丸めない。 +メンバーカードは名前・状態の丸・役割を表示し、詳細からmodel等を確認できる。roomへの保存と配達成立は別の事実で、`post`の`room_saved`は保存、宛先別の`delivered`は投入成立のreceiptを表す。`members`はserverが計算した実効状態とbridge healthを返す。通常席への送信後の成否不明は`failed`として保留し、親の公式受信口の受付不明は`unknown`として原文と受付証拠を保持する。どちらも本文を自動再送しない。Claudeも統合配達APIを使い、未知の承認や判定不能を成功へ丸めない。 API: `GET /api//messages`・`members`・`members/`・`summary`・`events`、`POST /api//messages`・`members`。MCPには`post`・`read_unread`・`read_log`・`members`・`delivery_status`を提供する。 diff --git a/README.md b/README.md index 1650e35a..5699ba9c 100644 --- a/README.md +++ b/README.md @@ -135,11 +135,11 @@ setupの再実行も既存projectではresumeへ進み、room・議題・生存 teardownの既定は解散。席と所有する足場を撤去し、roomと過去ログ、Lattice storeを残す。`--purge`はroomと新設storeも削除する。既存設定・無関係な作業差分は保つ。停止またはroom操作に失敗した場合は再実行用の記録を残してエラーを返す。 -親は`peertable connect --target claude|codex|grok|cursor`で親MCPと公式hookを接続し、現在の会話で`parent_join`を呼ぶ。親宛DM・複数人宛・all全件を原文のまま受信する。Cursor/Grokは返却された完成済みnative背景tool入力を登録する。接続設定だけで着卓完了とせず、耳疎通と現在の受信継続を確認する。12組合せの製品実機受入は[進行中の計画](https://github.com/kitepon/peertable/blob/main/docs/plan_parent-native-delivery.md)で管理している。親の耳疎通、kickoffの引受確認、席設定変更の手順は[同梱スキル](skill/SKILL.md)にまとめている。 +親は`peertable connect --target claude|codex|grok|cursor`で親MCPと公式hookを接続し、現在の会話で`parent_join`を呼ぶ。親宛DM・複数人宛・all全件を原文のまま受信する。Cursor/Grokは返却された完成済みnative背景tool入力を登録する。接続設定だけで着卓完了とせず、耳疎通と現在の受信継続を確認する。親配送の現行契約と受入の入口は[現行設計](https://github.com/kitepon/peertable/blob/main/docs/current-design.md)を参照する。親の耳疎通、kickoffの引受確認、席設定変更の手順は[同梱スキル](skill/SKILL.md)にまとめている。 **roomがメンバーの唯一の台帳。** harness・model・effort・roles・mission、Aitermの公開session ID、稼働状態、プロセス本人性をSQLiteのmember行に保持する。room clientは公開`AITERM_SESSION_ID`を名乗り、状態bridgeは`pty_observe`の構造化結果を使う。PeertableはAitermの内部ファイル、socket、namespace、画面文言を解析しない。 -メンバーカードは名前・状態の丸・役割を表示し、詳細からmodel等を確認できる。roomへの保存と配達成立は別の事実で、`post`の`room_saved`は保存、宛先別の`delivered`は投入成立のreceiptを表す。`members`はserverが計算した実効状態とbridge healthを返す。送信後の成否不明は`failed`として保留し、本文を自動再送しない。Claudeも統合配達APIを使い、未知の承認や判定不能を成功へ丸めない。 +メンバーカードは名前・状態の丸・役割を表示し、詳細からmodel等を確認できる。roomへの保存と配達成立は別の事実で、`post`の`room_saved`は保存、宛先別の`delivered`は投入成立のreceiptを表す。`members`はserverが計算した実効状態とbridge healthを返す。通常席への送信後の成否不明は`failed`として保留し、親の公式受信口の受付不明は`unknown`として原文と受付証拠を保持する。どちらも本文を自動再送しない。Claudeも統合配達APIを使い、未知の承認や判定不能を成功へ丸めない。 API: `GET /api//messages`・`members`・`members/`・`summary`・`events`、`POST /api//messages`・`members`。MCPには`post`・`read_unread`・`read_log`・`members`・`delivery_status`を提供する。 diff --git a/deploy/README.md b/deploy/README.md index 79843508..de61fbd2 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -29,6 +29,8 @@ docker-buildx build --platform linux/amd64 --load -t peertable-room:$TAG room/ **`docker build` で代用しない。`docker buildx build` を直接叩く。** Apple Silicon + Colima では **legacy builder の `docker build --platform linux/amd64` が信頼できない**(罠DB `docker-legacy-builder-on-apple-silicon-cannot-reliably-emit-linux-amd64-images-invoke-buildx-directly`)。「同じことだから」と書き換えたくなる場所なので明記する。 +roomのimageには`server.mjs`と、親の配送種別・healthを定義する`parent-kind.mjs`を含める。DockerfileのCOPY対象とserverのimportを照合する。親client・hook・spoolは利用端末のnpm packageが所有し、room imageへ載せない。 + ### 2. 運搬(registry 無し・LAN 直送) ```bash @@ -70,6 +72,10 @@ curl -sS -D- -o /dev/null https://peertable.kitepon.dev/api//members | gre curl -sN --max-time 30 https://peertable.kitepon.dev/api//events | head -6 # event: ping が25秒以内に来るか ``` +## 親配送の変更を反映する時 + +親配送を含むreleaseでは、`npm run verify:parent-delivery`の合格と既定ブランチへの着地を先に確認する。roomの親`parent_receiver`、宛先別`unknown` receiptとhealth、UTF-8本文保存はサーバー側の契約であり、clientの導入だけで本番反映済みとしない。roomの入替と公開API確認を済ませてから、利用端末へregistry版をglobal installし、同じ親会話へのDM・複数人宛・allの公開後smokeを行う。旧imageへ戻す時もログvolumeは保持する。 + ## ロールバック **旧タグの image が MS-A2 に残っている限り、`compose.yaml` の `image:` を戻して `up -d` するだけで戻る。** diff --git a/docs/00_overview.md b/docs/00_overview.md index 5c156dcd..6bf75098 100644 --- a/docs/00_overview.md +++ b/docs/00_overview.md @@ -8,6 +8,7 @@ Peertableは単独cloneで動作し、自身のsource、state、schema、着席 - [AGENTS.md](../AGENTS.md) — 開発、文書寿命、releaseの製品規約。 - [skill/SKILL.md](../skill/SKILL.md) — install、setup、resume、teardownと円卓運用の正規入口。 - [docs/current-design.md](current-design.md) — 製品境界、正本、協働、配送、runtime、releaseの現行設計契約。 +- [rag/parent-delivery/receivers.md](../rag/parent-delivery/receivers.md) — 親の公式受信口の一次資料・実測・受入目録への入口。 - [deploy/README.md](../deploy/README.md) — room本番のreleaseとrollback。 - [skill/02_models.snapshot.md](../skill/02_models.snapshot.md) — provenance付き着席配置の製品内既定値。外部表は明示opt-in時だけ使う。 @@ -22,6 +23,9 @@ Peertableは単独cloneで動作し、自身のsource、state、schema、着席 ## 検証 +- 親配送focused test: `node --test skill/scripts/parent-delivery.test.mjs room/parent-client.test.mjs` +- 親配送実機gate: `npm run verify:parent-delivery`。実行面とscenarioは生成目録、結果は`rag/parent-delivery/product-acceptance.json`が正本。 +- 親接続: `peertable connect --target claude|codex|grok|cursor`。実会話の`parent_join`後に`peertable diagnostics `で耳疎通と現在の受信継続を確認する。 - runtime contract: `node --test skill/scripts/runtime-contract.test.mjs` - 着席配置: `node experiments/seat-placement-repro.mjs` - package gate: `npm pack --dry-run` と `npm run prepublishOnly` diff --git a/docs/current-design.md b/docs/current-design.md index 0a238730..c8e383e0 100644 --- a/docs/current-design.md +++ b/docs/current-design.md @@ -11,6 +11,7 @@ Peertableは単独cloneで次を所有する。 - roomサーバー、セッションクライアント、読み取り専用Web UI - スキルinstall、明示projectへのsetup・resume・teardown、席・bridge・生成物のライフサイクル - source、schema、state、診断、着席配置snapshot +- 親MCPと公式hookの接続、親の受信登録・購読・原文spool - npm release、room本番release、rollback、製品CI dotagentsは任意の工場統合とhost配線を統括するが、Peertableの製品判断や既定挙動は制御しない。LatticeとAitermも隣接製品であり、Peertableは公開入口だけを利用し、内部stateを複製・直書きしない。 @@ -46,7 +47,7 @@ claimはroomへの`[claim] task-id`投稿で成立する。append-only logの先 ## 5. 配送と状態 -roomへの保存と席のTUIへの配達は別の事実である。message投稿は`room_saved`と宛先別`delivery`を返す。`delivered`を記録できるのは、wakeup bridgeが実際の投入成立をreceiptした時だけである。 +roomへの保存と席のTUIへの配達は別の事実である。message投稿は`room_saved`と宛先別`delivery`を返す。`delivered`を記録できるのは、通常席のwakeup bridgeまたは親の公式受信口が実際の受付・出力成立をreceiptした時だけである。 member状態はroomサーバーが、登録情報、seat-status、bridge health、更新時刻から計算する。各clientや親が独自に状態を推測して第二の台帳を持たない。 @@ -56,15 +57,28 @@ member状態はroomサーバーが、登録情報、seat-status、bridge health Web UIのメンバーカードは**オーナー意匠**である(裁定 2026-08-30): 表記は名前+状態の丸+**役割**だけ、状態の文字ラベルは置かない、カード幅は名前行までとし下段は省略表示する。役割は「他のメンバーが誰に何を頼むかを判断する」ための静的表記であり、mission・現在作業で置き換えない(missionはクリック時の詳細面だけに出す)。missionの語義は「フェーズ・campaign全体での席の担い」であり、現在作業・待機状態を書く欄ではない(現在作業はroom発言からの機械導出が別に担う。裁定 2026-08-30)。この意匠の変更はオーナー裁定必須であり、AIの改善判断で触らない——2026-08-30にmissionチップが役割表記を無断で上書きし、オーナー設計を破壊した実被弾がこの条文の由来である。 +### 親の公式受信口 + +親の`delivery.kind`は`parent_receiver`とし、通常席のwakeup bridgeから除外する。親は現在の実会話から`parent_join`で登録し、`parent_read`で原文を回収し、`parent_leave`で受信登録を閉じる。親宛DM、親を含む複数人宛、all全件を配送する。親自身の発言と他席間DMは対象外である。 + +Peertableは原文spoolと受信cursor、Claudeの公式asyncRewake、Codexの公式queueと同期hook、Cursorの公式hookとnative背景Shell、Grokのnative背景完了と公開出力回収を所有する。ユーザー領域の`~/.peertable/parent-receivers/`は接続・実会話相関・共有slotを、projectの`.team/parent-delivery/`は配送本文と進行を持つ。room台帳は宛先別receiptと親のhealthの正本である。 + +耳疎通の`verified`と現在の受信継続は別に確認する。runtimeは`armed`、`rearm_pending`、`stopped`、`failed`を区別し、Cursor/Grokはreceiptの完成済みnative tool入力を登録して次の受信を維持する。故障は宛先別receipt、health、診断へ原因code付きで出す。旧parent_watchは移行診断の対象である。 + +受付不明は`unknown`として原文と受付証拠を保持し、自動再送しない。Codexのqueue受付ID・時刻は後続receiptでも保持する。長文は同じ配送ID・digestへ束縛した継続tokenで最後まで読み、最後の出力完了までackしない。 + +このcampaignの製品実機受入・公開は未完了。[親配送計画](plan_parent-native-delivery.md)と[実機受入目録](../rag/parent-delivery/product-acceptance.json)の全必須面が成立するまで公開判定を行わない。 + ## 6. ライフサイクルと実行基盤 setup、resume、teardownはそれぞれ一回の製品入口で必要な順序を完結させる。利用AIへbridge停止、更新、再起動、ready確認の順序選択を委ねない。 - global npm installは検出したAIのスキル配置を更新する。再実行入口は`peertable install`。所有するPeertableリンクだけを管理し、共有AI設定本文や他製品の設定を変更しない。installからprojectの再構築を行わない。 +- 親の接続は`peertable connect --target claude|codex|grok|cursor`が所有する。Peertable専用MCP・公式hookだけを管理し、他製品の設定・承認・順序を保持する。設定追加だけで実会話への受信成立としない。 - setupは明示projectの生成物、接続、bridge起動、ready確認までを持つ。再実行時は既存roomと議題を保ってresumeへ進む。席の追加は`peertable launch`がroom登録と実ターン開始まで確認する。 - 着任指示の成立まで、その席への通常通知はpendingに保持する。初回指示と参加通知の配達順序はPeertableが所有し、起動中断の記録は再着席前の退席処理で解除する。 - resumeはPeertable所有の生成物とroom MCPを現行treeへ同期してから席を復帰する。 -- teardownはwakeup、seat-status、alarmの3 bridgeを停止してから生成物を片付ける。既定は席と足場だけを畳み、room履歴とLattice storeを残す。痕跡ゼロは明示した`--purge`だけで行う。 +- teardownは親の購読・背景受信とwakeup、seat-status、alarmの3 bridgeを停止してから生成物を片付ける。親のharness processは終了せず、親の配送履歴と受付不明の本文を保持する。既定は席と足場だけを畳み、room履歴とLattice storeを残す。痕跡ゼロは明示した`--purge`だけで行う。 roomは解散状態(archive)を持つ。teardownがroomをarchiveし、公開一覧(`/api/rooms`のrooms欄とトップページ主一覧)から外す。個別ページとログAPIは読めるまま残る。次のsetupのmember登録が同じroomを自動で現役へ戻す。公開面に並ぶのは現役の卓だけである。 @@ -84,14 +98,10 @@ root MCPはblock単位の所有を記録し、既存の他のblockを保つ。Cu npm releaseとroom本番のrelease/rollbackはPeertable自身が所有する。公開対象commitは既定ブランチの祖先に限り、npmは公開済み版を書き換えずfix-forwardする。npmの緊急退避は既知正常versionを明示installし、復旧後に最新へ戻す。room本番の手順とrollbackは[deploy/README.md](../deploy/README.md)を正とする。 +親配送のrelease gateは`npm run verify:parent-delivery`で実機受入目録を照合する。OS・harness・実行面・scenarioの期待項目は`scripts/parent-delivery-acceptance.mjs`が生成し、未実施・fixtureによる代替・検証後のsource変更・digest/version不一致を拒否する。最終sourceと配布packageを実測してからtagを作り、公開後にregistry版の導入と配送を再確認する。 + CIはこのリポジトリ内の再利用workflowを製品正本とする。外部dotagentsリポジトリのworkflowを製品CIの実行本体にしない。 ## 9. 文書寿命 現行の入口は[文書地図](00_overview.md)である。`docs/`直下には現行契約と状態照合中のcampaignだけを置く。完了・supersededした計画と累積decision logは`docs/archive/`へ移し、通常の読書順から外す。固定consumerが実在する時だけ、旧pathへ短い互換案内を残す。 - -## 親の公式受信口 - -親のdelivery.kindはparent_receiverとし、通常席bridgeから除外する。Peertableは原文spoolと3親MCP tool、Claude asyncRewake、Codex公式queueと同期hook、Cursor公式hookとnative背景受信、Grok native背景完了と本文回収を所有する。allは親にも全件配送し、他席間DMと自発言は対象外。旧parent_watchは移行診断の対象。受付不明はunknownを保持し、queue受付ID・時刻を消さず本文を自動再送しない。 - -このcampaignの製品実機受入・公開は未完了。[親配送計画](plan_parent-native-delivery.md)の12組合せと全必須実行面を受入するまで公開判定を行わない。 diff --git a/docs/plan_parent-native-delivery.md b/docs/plan_parent-native-delivery.md index 93bb395d..09f5a6c0 100644 --- a/docs/plan_parent-native-delivery.md +++ b/docs/plan_parent-native-delivery.md @@ -1,6 +1,6 @@ # 親セッションへの配送設計 -状態: 実装・CLI実測中。allは親にも全件配送する。12組合せの全条件の実機受入は未達。 +状態: 実装済み・修理版の実機受入中。allは親にも全件配送する。12組合せの全条件の実機受入は未達。 ## 1. 依頼と完了条件 @@ -291,7 +291,7 @@ fixtureが必要とする設定は公式の一時設定指定と使い捨てproj ### 第4工程: 12組合せの受入 -下の12行はすべて必須で、fixture試験と実認証の親セッション試験を別に記録する。設計時点では全行未実施である。 +下の12行はすべて必須で、fixture試験と実認証の親セッション試験を別に記録する。12行はOS×harnessを表し、Codexの3面・Cursorの2面を個別に展開するとOSごとに7実行面になる。各面の全scenarioを展開した期待目録は`scripts/parent-delivery-acceptance.mjs`が生成し、実機の合否は`rag/parent-delivery/product-acceptance.json`だけを正本とする。表のfixture列・実機列は設計時点の記録であり、現在の合格数を複製しない。 | OS | ハーネス | 必須実行面 | 同じ配送機構 | fixture | 実機 | | --- | --- | --- | --- | --- | --- | diff --git a/package-lock.json b/package-lock.json index 28cab63d..7c035019 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "peertable", - "version": "0.8.62", + "version": "0.8.63", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "peertable", - "version": "0.8.62", + "version": "0.8.63", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0" diff --git a/package.json b/package.json index 523701d0..9d96d871 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "peertable", - "version": "0.8.62", + "version": "0.8.63", "description": "A round table of peer agents. No orchestrator at the head. Turn Claude Code, Codex, Grok, and Cursor sessions into a team of equal, long-lived peers.", "type": "module", "license": "MIT", diff --git a/skill/SKILL.md b/skill/SKILL.md index 547c48f1..09773586 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -72,7 +72,7 @@ seat-statusは`pty_observe`の状態・本人性・token hint・活動差分と ## resume -`peertable resume [--plan ] [--phase ] [--no-probe]` は既存`.team/`を基に生成物を更新し、登録された席の復帰と3 bridgeの更新を行い、fresh heartbeatとprobeのdelivered receiptを読み返す。生存席・roomログ・他projectは保つ。役割のない復帰対象はエラーで止まる。`--no-probe`は明示的に配達試験を省略する時だけ使う。親の監視は呼出し元に属するため、parent-joinと耳疎通を現在のセッションで行う。 +`peertable resume [--plan ] [--phase ] [--no-probe]` は既存`.team/`を基に生成物を更新し、登録された席の復帰と3 bridgeの更新を行い、fresh heartbeatとprobeのdelivered receiptを読み返す。生存席・roomログ・他projectは保つ。役割のない復帰対象はエラーで止まる。`--no-probe`は明示的に配達試験を省略する時だけ使う。親の監視は呼出し元に属するため、`parent_join`と耳疎通を現在の実会話で行う。Cursor/Grokは返却されたnative背景tool入力を登録し、`rearm_pending`から`armed`への進行を確認する。耳疎通probeの期限はnative receiverの準備確認後から計る。 ## 席設定とmission From a07630d2feae01945cc1a34dcd2247676c95ecd0 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:38:19 +0900 Subject: [PATCH 09/72] =?UTF-8?q?client=E3=81=AE=E7=89=88=E7=95=AA?= =?UTF-8?q?=E5=8F=B7=E3=82=92=E9=85=8D=E5=B8=83=E5=80=99=E8=A3=9C0.8.63?= =?UTF-8?q?=E3=81=B8=E6=8F=83=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CIのversion_consistencyで再現した更新漏れを修正する。package単体診断は全項目readyを確認済み。 --- room/client.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/room/client.mjs b/room/client.mjs index e2dca2af..d53c1cc7 100755 --- a/room/client.mjs +++ b/room/client.mjs @@ -14,7 +14,7 @@ import { boundedRecent, boundedUnread } from './message-bounds.mjs' // client.mjs 側のハードコード版数。package.json の version と一致していることを // diagnostics の version_consistency が見る(2 つの版数源の drift 検出。決定45) -const MCP_VERSION = '0.8.62' +const MCP_VERSION = '0.8.63' const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') const USAGE = `usage: From 9a118095e943d903d08184ac26ea770a392d1f73 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:50:01 +0900 Subject: [PATCH 10/72] =?UTF-8?q?=E9=85=8D=E5=B8=83=E5=80=99=E8=A3=9C?= =?UTF-8?q?=E3=81=AE=E5=AE=9F=E6=B8=AC=E3=81=A8GUI=E8=A6=B3=E6=B8=AC?= =?UTF-8?q?=E3=81=AE=E5=81=9C=E6=AD=A2=E4=BD=8D=E7=BD=AE=E3=82=92=E8=A8=98?= =?UTF-8?q?=E9=8C=B2=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 OSの製品CI、正式tarball導入診断、公開gate未達を区別して保存する。 未送信のGUI試験の後片付けと、正式受入から公開までの再開条件を文書へ残す。 --- docs/plan_parent-native-delivery.md | 8 ++++ rag/parent-delivery/receivers.md | 6 +++ .../release-candidate-checkpoint.json | 40 +++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 rag/parent-delivery/release-candidate-checkpoint.json diff --git a/docs/plan_parent-native-delivery.md b/docs/plan_parent-native-delivery.md index 09f5a6c0..ea28f2fc 100644 --- a/docs/plan_parent-native-delivery.md +++ b/docs/plan_parent-native-delivery.md @@ -357,3 +357,11 @@ fixtureは実process境界・競合・障害を速く再現する。実機は親 この設計のscopeは4 receiversと12組合せをまとめた一つの完成条件である。工程分割は実装順序であり、Codexだけ、POSIXだけ、1通目だけ等の部分実装を完成として公開する許可ではない。 第1工程で未確定と明記した外部契約は、実測してこの文書とfixtureを更新する。任意の省略・新方式への変更・Aiterm改造・責務の移動が必要になった場合は、差分と根拠を示してオーナーと合意する。既存の仕様を私見で減らさない。 + +## 11. 外部観測待ちからの再開 + +配布候補のsource・版・CI・package確認と、GUI観測の停止位置は[チェックポイント](../rag/parent-delivery/release-candidate-checkpoint.json)を参照する。実装文書と配布候補はcommit・push済みだが、実機受入manifestが未達のため、mainへの着地・tag・npm公開・registry導入・本番反映は残る。 + +GUI試験はJevによる画面取得で停止した。必要なOS権限は確認済みで、専用Cursor projectへの試験メッセージは未送信である。オーナーへGUI試験に限る操作ツール変更を確認中。許可前に代替操作へ進まず、専用の試験ウィンドウとサーバーは閉じている。 + +独立して、修理版のCursor/Grok通常CLIの再測定と、最終候補をpackしてClaude/Codex通常CLIのscenario固有証拠を取得するrunnerの準備を進める。再開時は最新sourceと配布物を照合して実機目録を完成させ、関連する最終文書を更新し、第5工程の公開・導入・本番確認を続ける。古いsnapshotの成功やCIの合格だけで実機目録を埋めない。 diff --git a/rag/parent-delivery/receivers.md b/rag/parent-delivery/receivers.md index 9d3c047a..51510107 100644 --- a/rag/parent-delivery/receivers.md +++ b/rag/parent-delivery/receivers.md @@ -45,3 +45,9 @@ Cursor/Grokの6 CLIは旧snapshotで5件失敗、Windows Grokの無改造試験 親のCodex idle長文試験でroomのUTF-8断片decodeによる原文破損が再現された。`readBody`をNodeのstream decoderへ修理し、[HTTP UTF-8再現](../../experiments/http-utf8-body-repro.mjs)で40,097 UTF-16文字の日本語・emojiの途中を5箇所で分割する。POST応答、GET messages、保存logの完全一致をfocused検証する。修理前のnative idle長文試験は失敗証拠として親が保管し、成功へ変更しない。 公開前gateは`npm run verify:parent-delivery`で[実機manifest](product-acceptance.json)を検査する。3 OS・7実行面・25scenarioの525件を要求し、欠落/skip/fixture・証拠file欠落・source digest/version不一致を拒否する。現manifestは空で、公開gateは未達。gateは会話/原文/receipt等の共通証拠を機械照合し、scenario固有の実観測の十分性は親の受入監査でも確認する。実装チェックポイントと全製品受入完了を分ける。 + +## 配布候補の確認 + +最新の固定候補のsource、version、digest、製品CI、packと導入診断、公開gateの実行結果は[配布候補のチェックポイント](release-candidate-checkpoint.json)に保存する。3 OSの製品CIと正式tarballからの導入診断は成立したが、実機受入manifestの不足は残る。tarballを専用local prefixへnpmで導入した確認を、registry版のglobal installや本番反映として報告しない。 + +Cursorの専用Desktop projectは正式tarballから構成した。OS権限の確認は成立したが、Jevの画面取得がwindow非公開・複数windowの相関・OS inventoryのtimeoutで停止し、初回メッセージは未送信である。専用ウィンドウとroom serverは停止した。モデルの利用上限やPeertableの配送不成立と同じ原因にしない。GUI試験の操作ツール変更はオーナーへの確認中であり、CLIの正式受入runnerは独立に準備している。 diff --git a/rag/parent-delivery/release-candidate-checkpoint.json b/rag/parent-delivery/release-candidate-checkpoint.json new file mode 100644 index 00000000..72067105 --- /dev/null +++ b/rag/parent-delivery/release-candidate-checkpoint.json @@ -0,0 +1,40 @@ +{ + "schema": "peertable.parent-release-checkpoint.v1", + "source_commit": "a07630d2feae01945cc1a34dcd2247676c95ecd0", + "runtime_digest": "661241c94f40e184eb0c7d9af295d50cc7ff4e143ecd1d0661703316517e904f", + "package_version": "0.8.63", + "product_ci": { + "url": "https://github.com/kitepon/peertable/actions/runs/36452495551", + "head_commit": "a07630d2feae01945cc1a34dcd2247676c95ecd0", + "native_os": { "darwin": "passed", "linux": "passed", "win32": "passed" } + }, + "documentation_tests": { "passed": 6, "failed": 0, "skipped": 0 }, + "package": { + "filename": "peertable-0.8.63.tgz", + "sha256": "fcf17b36fc9522d5c9b72637f1a20803c1e1ba677e209848a8c5ddf918084300", + "pack_dry_run_files": 128, + "install_source": "npm_pack_tarball", + "install_scope": "fixture_local_prefix", + "diagnostics": "ready", + "registry_install": false + }, + "product_acceptance": { + "manifest": "rag/parent-delivery/product-acceptance.json", + "status": "failed", + "expected": 525, + "records": 0, + "errors": { "PARENT_ACCEPTANCE_MISSING": 525 }, + "checkpoint_counts_as_live_acceptance": false + }, + "cursor_desktop_observation": { + "status": "blocked", + "message_sent": false, + "tool": "jev-desktop", + "observation_errors": ["ACTION_NOT_SUPPORTED", "AMBIGUOUS_TARGET", "CoreGraphics window inventory timed out"], + "permission_checks": { "accessibility": "granted", "screen_recording": "granted" }, + "test_window_closed": true, + "test_server_alive_after_cleanup": false + }, + "published": false, + "production_deployed": false +} From a6935a4c2e8893ce84ded22a993afbd4da2d3faf Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:10:32 +0900 Subject: [PATCH 11/72] =?UTF-8?q?=E8=A6=AA=E9=85=8D=E9=80=81=E3=81=AE?= =?UTF-8?q?=E4=BF=AE=E7=90=86=E8=A6=B3=E6=B8=AC=E3=81=A8=E5=AE=9F=E6=A9=9F?= =?UTF-8?q?=E8=A9=A6=E9=A8=93=E3=81=AE=E6=BA=96=E5=82=99=E3=82=92=E8=A8=98?= =?UTF-8?q?=E9=8C=B2=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plan_parent-native-delivery.md | 8 +- .../cursor-grok-repair-observation.json | 715 ++++++++++++++++++ .../gui-environment-preparation.md | 9 + rag/parent-delivery/room-image-preflight.json | 15 + 4 files changed, 746 insertions(+), 1 deletion(-) create mode 100644 rag/parent-delivery/cursor-grok-repair-observation.json create mode 100644 rag/parent-delivery/gui-environment-preparation.md create mode 100644 rag/parent-delivery/room-image-preflight.json diff --git a/docs/plan_parent-native-delivery.md b/docs/plan_parent-native-delivery.md index ea28f2fc..810bee72 100644 --- a/docs/plan_parent-native-delivery.md +++ b/docs/plan_parent-native-delivery.md @@ -364,4 +364,10 @@ fixtureは実process境界・競合・障害を速く再現する。実機は親 GUI試験はJevによる画面取得で停止した。必要なOS権限は確認済みで、専用Cursor projectへの試験メッセージは未送信である。オーナーへGUI試験に限る操作ツール変更を確認中。許可前に代替操作へ進まず、専用の試験ウィンドウとサーバーは閉じている。 -独立して、修理版のCursor/Grok通常CLIの再測定と、最終候補をpackしてClaude/Codex通常CLIのscenario固有証拠を取得するrunnerの準備を進める。再開時は最新sourceと配布物を照合して実機目録を完成させ、関連する最終文書を更新し、第5工程の公開・導入・本番確認を続ける。古いsnapshotの成功やCIの合格だけで実機目録を埋めない。 +修理版のCursor/Grok通常CLIの再測定は終了し、[修理版の観測](../rag/parent-delivery/cursor-grok-repair-observation.json)へ保存した。6セルのroom原文hash、保存された本文、receipt revision、親の会話/PIDと最終native task IDを照合した。macOS Grokは画面上の複数宛本文の末尾が未確認のため部分確認である。旧候補の観測を最終候補の525件へ流用しない。 + +最終候補をpackした配布物で、Claude/Codex通常CLIのscenario固有証拠を取得するrunnerと、Cursor/Grok通常CLIのaudience条件の実測を進める。再開時は最新sourceと配布物を照合して実機目録を完成させ、関連する最終文書を更新し、第5工程の公開・導入・本番確認を続ける。古いsnapshotの成功やCIの合格だけで実機目録を埋めない。 + +GUI準備では3 OSのCodex App、macOSとWindowsのCursor Desktopを確認し、不足していたLinuxのVS Code/Cursor Desktop、WindowsのVS Codeと各Codex拡張を公式installer・package managerで導入した。アプリと拡張の存在確認は配送の合格に数えない。公式導入方法は[環境準備の記録](../rag/parent-delivery/gui-environment-preparation.md)を参照する。 + +本番向けLinux imageの開発機での確認は[image確認](../rag/parent-delivery/room-image-preflight.json)へ保存した。HTTP保存/読出しのUTF-8原文、読取CORS、SSE接続は合格し、試験containerは停止済みである。本番未反映であり、親のnative配送の受入を代替しない。 diff --git a/rag/parent-delivery/cursor-grok-repair-observation.json b/rag/parent-delivery/cursor-grok-repair-observation.json new file mode 100644 index 00000000..83b38216 --- /dev/null +++ b/rag/parent-delivery/cursor-grok-repair-observation.json @@ -0,0 +1,715 @@ +{ + "schema": "peertable.cursor-grok-repair-observation.v1", + "source": { + "commit": "dd5fbf41b84058d2e10a247374041bb89a54fc43", + "runtime_digest": "2edd2fab80335c9d8d295ca077788ed21ecf502dfb137235df6e80e36f815ab6", + "tree": "git archive of the commit (clean); no product file patched", + "package_version": "0.8.62", + "digest_verified_on": [ + "darwin", + "linux", + "win32" + ] + }, + "harness_versions": { + "cursor": "2026.09.26-dd393fe", + "grok": "1.0.41 (4220f3b224a6)" + }, + "counts_as_final_live_acceptance": false, + "root_checks": [ + { + "cell": "macos-cursor", + "room_original_hashes_match": true, + "final_spool_originals_equal": 3, + "receipt_revisions_match": true, + "caller_pid_and_conversation_match": true, + "final_task_id_matches": true, + "hook_event_count": 26, + "tui_status": "observed" + }, + { + "cell": "macos-grok", + "room_original_hashes_match": true, + "final_spool_originals_equal": 3, + "receipt_revisions_match": true, + "caller_pid_and_conversation_match": true, + "final_task_id_matches": true, + "hook_event_count": 94, + "tui_status": "partial" + }, + { + "cell": "linux-cursor", + "room_original_hashes_match": true, + "final_spool_originals_equal": 3, + "receipt_revisions_match": true, + "caller_pid_and_conversation_match": true, + "final_task_id_matches": true, + "hook_event_count": 26, + "tui_status": "observed" + }, + { + "cell": "linux-grok", + "room_original_hashes_match": true, + "final_spool_originals_equal": 6, + "receipt_revisions_match": true, + "caller_pid_and_conversation_match": true, + "final_task_id_matches": true, + "hook_event_count": 62, + "tui_status": "observed" + }, + { + "cell": "windows-cursor", + "room_original_hashes_match": true, + "final_spool_originals_equal": 3, + "receipt_revisions_match": true, + "caller_pid_and_conversation_match": true, + "final_task_id_matches": true, + "hook_event_count": 26, + "tui_status": "observed" + }, + { + "cell": "windows-grok", + "room_original_hashes_match": true, + "final_spool_originals_equal": 3, + "receipt_revisions_match": true, + "caller_pid_and_conversation_match": true, + "final_task_id_matches": true, + "hook_event_count": 42, + "tui_status": "observed" + } + ], + "cells": [ + { + "cell": "macos-cursor", + "conversation_id": "1f041b33-5dd8-4c74-9221-38edaf759d0a", + "endpoint_id": "0e9ba0f7-3ba3-4a36-ae58-21275e409e0f", + "caller_owner": { + "pid": 87264, + "started": "Tue Sep 29 01:15:05 2026" + }, + "armed": { + "runtime": "armed", + "state": "verified", + "native_task_id": "900131", + "native_task_pid": 92879, + "native_task_started": "Tue Sep 29 01:16:25 2026", + "waiter_pid": 92916, + "waiter_started": "Tue Sep 29 01:16:25 2026" + }, + "messages": [ + { + "seq": 3, + "from": "worker", + "to": "all", + "body_sha256": "6fb07d2a5f9fdbf1ea9786b4f9a77bed1f79f0a86a147da2f662cf1460d643c7", + "body_chars": 32, + "receipt": { + "state": "delivered", + "reason": "cursor_native_background_output", + "at": "2026-09-28T16:15:41.031Z", + "revision": 1 + }, + "audience_check": "all", + "worker_receipt": null + }, + { + "seq": 4, + "from": "worker", + "to": "bell", + "body_sha256": "cc22a0dc0bcafaf1d43e86c3e8b3ebc116d9658a6293af9db4dfceb0ea2fc339", + "body_chars": 34, + "receipt": { + "state": "delivered", + "reason": "cursor_native_background_output", + "at": "2026-09-28T16:15:57.774Z", + "revision": 1 + }, + "audience_check": "dm(bell)", + "worker_receipt": null + }, + { + "seq": 5, + "from": "worker", + "to": [ + "bell", + "worker" + ], + "body_sha256": "0344d6989a18b1d659e1a8b7b5f2340e6aae74ebfb5a64b3ed918290b9f7e162", + "body_chars": 46, + "receipt": { + "state": "delivered", + "reason": "cursor_native_background_output", + "at": "2026-09-28T16:16:14.659Z", + "revision": 1 + }, + "audience_check": "multi(bell,worker)", + "worker_receipt": "seat_unavailable" + } + ], + "stages": { + "initial_join": "observed", + "original_saved_and_receipt": "observed(delivered x3)", + "armed_native_task_pid_start": "observed", + "native_completion_same_cid": "observed", + "rearm_second_onward": "observed", + "tui_original": "observed" + }, + "hook_events": { + "total": 26, + "nonzero_exit": 0, + "stdin_prefix_hex_set": [ + "7b22636f" + ] + } + }, + { + "cell": "macos-grok", + "conversation_id": "01a0e8d2-e829-74f0-b575-4a177a561b98", + "endpoint_id": "534c2645-515f-482b-83e8-01672fe7ec22", + "caller_owner": { + "pid": 4988, + "started": "Tue Sep 29 01:22:10 2026" + }, + "armed": { + "runtime": "armed", + "state": "verified", + "native_task_id": "01a0e8d7-1e6c-7e20-8798-3558746f1053", + "native_task_pid": 12923, + "native_task_started": "Tue Sep 29 01:26:47 2026", + "waiter_pid": 12927, + "waiter_started": "Tue Sep 29 01:26:47 2026" + }, + "messages": [ + { + "seq": 3, + "from": "worker", + "to": "all", + "body_sha256": "6fb07d2a5f9fdbf1ea9786b4f9a77bed1f79f0a86a147da2f662cf1460d643c7", + "body_chars": 32, + "receipt": { + "state": "delivered", + "reason": "parent_read_complete", + "at": "2026-09-28T16:18:22.161Z", + "revision": 1 + }, + "audience_check": "all", + "worker_receipt": null + }, + { + "seq": 4, + "from": "worker", + "to": "bell", + "body_sha256": "cc22a0dc0bcafaf1d43e86c3e8b3ebc116d9658a6293af9db4dfceb0ea2fc339", + "body_chars": 34, + "receipt": { + "state": "delivered", + "reason": "parent_read_complete", + "at": "2026-09-28T16:18:33.870Z", + "revision": 1 + }, + "audience_check": "dm(bell)", + "worker_receipt": null + }, + { + "seq": 5, + "from": "worker", + "to": [ + "bell", + "worker" + ], + "body_sha256": "0344d6989a18b1d659e1a8b7b5f2340e6aae74ebfb5a64b3ed918290b9f7e162", + "body_chars": 46, + "receipt": { + "state": "delivered", + "reason": "parent_read_complete", + "at": "2026-09-28T16:18:52.575Z", + "revision": 1 + }, + "audience_check": "multi(bell,worker)", + "worker_receipt": "seat_unavailable" + }, + { + "seq": 6, + "from": "worker", + "to": "all", + "body_sha256": "3bf28dbf936a43b44bebf44727fbad2274feb13b0a381eb8f47a75dfbf9e2096", + "body_chars": 34, + "receipt": { + "state": "delivered", + "reason": "parent_read_complete", + "at": "2026-09-28T16:19:27.900Z", + "revision": 1 + }, + "audience_check": "all", + "worker_receipt": null + }, + { + "seq": 7, + "from": "worker", + "to": "bell", + "body_sha256": "9749edf4f6f8be8d616a25b80608548de630b54f3a93c0f7bdab200c53b6f21b", + "body_chars": 36, + "receipt": { + "state": "delivered", + "reason": "parent_read_complete", + "at": "2026-09-28T16:20:09.086Z", + "revision": 1 + }, + "audience_check": "dm(bell)", + "worker_receipt": null + }, + { + "seq": 8, + "from": "worker", + "to": "all", + "body_sha256": "dcf74e99bf6d234bc16e4188cf3fe796dae0ad37e92f7494a3a5ed47b151a0fe", + "body_chars": 34, + "receipt": { + "state": "delivered", + "reason": "parent_read_complete", + "at": "2026-09-28T16:26:07.549Z", + "revision": 1 + }, + "audience_check": "all", + "worker_receipt": null + }, + { + "seq": 9, + "from": "worker", + "to": "bell", + "body_sha256": "25bbe848aaf94b408df1446ae615bf38a5e8faca8ae17b140441984a5156ad72", + "body_chars": 36, + "receipt": { + "state": "delivered", + "reason": "parent_read_complete", + "at": "2026-09-28T16:26:24.294Z", + "revision": 1 + }, + "audience_check": "dm(bell)", + "worker_receipt": null + }, + { + "seq": 10, + "from": "worker", + "to": [ + "bell", + "worker" + ], + "body_sha256": "16c87e316b7386083ef26011c7f8629723ae0e1dc792a55da7519c37ab1d0570", + "body_chars": 48, + "receipt": { + "state": "delivered", + "reason": "parent_read_complete", + "at": "2026-09-28T16:26:42.287Z", + "revision": 1 + }, + "audience_check": "multi(bell,worker)", + "worker_receipt": "seat_unavailable" + } + ], + "stages": { + "initial_join": "observed", + "original_saved_and_receipt": "observed(delivered x8)", + "armed_native_task_pid_start": "observed", + "native_completion_same_cid": "observed", + "rearm_second_onward": "observed", + "tui_original": "partial" + }, + "hook_events": { + "total": 94, + "nonzero_exit": 0, + "stdin_prefix_hex_set": [ + "7b22686f" + ] + } + }, + { + "cell": "linux-cursor", + "conversation_id": "a0c6c669-2682-47cf-85be-95c216accc50", + "endpoint_id": "4898cec1-b10a-4397-b3b2-6c88e329aa72", + "caller_owner": { + "pid": 1540757, + "started": "fa227cc0-c7c5-4c8d-bc23-b56674f51d33:53204262" + }, + "armed": { + "runtime": "armed", + "state": "verified", + "native_task_id": "37057", + "native_task_pid": 1547126, + "native_task_started": "fa227cc0-c7c5-4c8d-bc23-b56674f51d33:53211851", + "waiter_pid": 1547204, + "waiter_started": "fa227cc0-c7c5-4c8d-bc23-b56674f51d33:53211864" + }, + "messages": [ + { + "seq": 3, + "from": "worker", + "to": "all", + "body_sha256": "6fb07d2a5f9fdbf1ea9786b4f9a77bed1f79f0a86a147da2f662cf1460d643c7", + "body_chars": 32, + "receipt": { + "state": "delivered", + "reason": "cursor_native_background_output", + "at": "2026-09-28T16:28:55.743Z", + "revision": 1 + }, + "audience_check": "all", + "worker_receipt": null + }, + { + "seq": 4, + "from": "worker", + "to": "bell", + "body_sha256": "cc22a0dc0bcafaf1d43e86c3e8b3ebc116d9658a6293af9db4dfceb0ea2fc339", + "body_chars": 34, + "receipt": { + "state": "delivered", + "reason": "cursor_native_background_output", + "at": "2026-09-28T16:29:12.530Z", + "revision": 1 + }, + "audience_check": "dm(bell)", + "worker_receipt": null + }, + { + "seq": 5, + "from": "worker", + "to": [ + "bell", + "worker" + ], + "body_sha256": "0344d6989a18b1d659e1a8b7b5f2340e6aae74ebfb5a64b3ed918290b9f7e162", + "body_chars": 46, + "receipt": { + "state": "delivered", + "reason": "cursor_native_background_output", + "at": "2026-09-28T16:29:27.571Z", + "revision": 1 + }, + "audience_check": "multi(bell,worker)", + "worker_receipt": "seat_unavailable" + } + ], + "stages": { + "initial_join": "observed", + "original_saved_and_receipt": "observed(delivered x3)", + "armed_native_task_pid_start": "observed", + "native_completion_same_cid": "observed", + "rearm_second_onward": "observed", + "tui_original": "observed" + }, + "hook_events": { + "total": 26, + "nonzero_exit": 0, + "stdin_prefix_hex_set": [ + "7b22636f" + ] + } + }, + { + "cell": "linux-grok", + "conversation_id": "01a0e8d9-f4e5-76f0-a730-4adf861aabb1", + "endpoint_id": "a781cce7-ba73-45da-b759-a2c66289fde0", + "caller_owner": { + "pid": 1547753, + "started": "fa227cc0-c7c5-4c8d-bc23-b56674f51d33:53213179" + }, + "armed": { + "runtime": "armed", + "state": "verified", + "native_task_id": "01a0e8e1-1047-71e2-82a6-47fed2530b84", + "native_task_pid": 1554120, + "native_task_started": "fa227cc0-c7c5-4c8d-bc23-b56674f51d33:53260089", + "waiter_pid": 1554125, + "waiter_started": "fa227cc0-c7c5-4c8d-bc23-b56674f51d33:53260090" + }, + "messages": [ + { + "seq": 3, + "from": "worker", + "to": "all", + "body_sha256": "6fb07d2a5f9fdbf1ea9786b4f9a77bed1f79f0a86a147da2f662cf1460d643c7", + "body_chars": 32, + "receipt": { + "state": "delivered", + "reason": "parent_read_complete", + "at": "2026-09-28T16:32:01.338Z", + "revision": 1 + }, + "audience_check": "all", + "worker_receipt": null + }, + { + "seq": 4, + "from": "worker", + "to": "bell", + "body_sha256": "cc22a0dc0bcafaf1d43e86c3e8b3ebc116d9658a6293af9db4dfceb0ea2fc339", + "body_chars": 34, + "receipt": { + "state": "delivered", + "reason": "parent_read_complete", + "at": "2026-09-28T16:33:47.847Z", + "revision": 1 + }, + "audience_check": "dm(bell)", + "worker_receipt": null + }, + { + "seq": 5, + "from": "worker", + "to": [ + "bell", + "worker" + ], + "body_sha256": "0344d6989a18b1d659e1a8b7b5f2340e6aae74ebfb5a64b3ed918290b9f7e162", + "body_chars": 46, + "receipt": { + "state": "delivered", + "reason": "parent_read_complete", + "at": "2026-09-28T16:35:15.864Z", + "revision": 1 + }, + "audience_check": "multi(bell,worker)", + "worker_receipt": "seat_unavailable" + }, + { + "seq": 6, + "from": "worker", + "to": "all", + "body_sha256": "3bf28dbf936a43b44bebf44727fbad2274feb13b0a381eb8f47a75dfbf9e2096", + "body_chars": 34, + "receipt": { + "state": "delivered", + "reason": "parent_read_complete", + "at": "2026-09-28T16:37:00.925Z", + "revision": 1 + }, + "audience_check": "all", + "worker_receipt": null + }, + { + "seq": 7, + "from": "worker", + "to": "bell", + "body_sha256": "9749edf4f6f8be8d616a25b80608548de630b54f3a93c0f7bdab200c53b6f21b", + "body_chars": 36, + "receipt": { + "state": "delivered", + "reason": "parent_read_complete", + "at": "2026-09-28T16:37:14.543Z", + "revision": 1 + }, + "audience_check": "dm(bell)", + "worker_receipt": null + }, + { + "seq": 8, + "from": "worker", + "to": [ + "bell", + "worker" + ], + "body_sha256": "4cd6b0630ba943cc8b08619ba77c2779ddbe7ad8fa7770d0684ba7232ca23fb1", + "body_chars": 47, + "receipt": { + "state": "delivered", + "reason": "parent_read_complete", + "at": "2026-09-28T16:37:34.401Z", + "revision": 1 + }, + "audience_check": "multi(bell,worker)", + "worker_receipt": "seat_unavailable" + } + ], + "stages": { + "initial_join": "observed", + "original_saved_and_receipt": "observed(delivered x6)", + "armed_native_task_pid_start": "observed", + "native_completion_same_cid": "observed", + "rearm_second_onward": "observed", + "tui_original": "observed" + }, + "hook_events": { + "total": 62, + "nonzero_exit": 0, + "stdin_prefix_hex_set": [ + "7b22686f" + ] + } + }, + { + "cell": "windows-cursor", + "conversation_id": "40bb6fa4-efc9-4d6b-8841-c015c3c6d019", + "endpoint_id": "eadb8b30-03e5-4317-ba92-825963d9e4ee", + "caller_owner": { + "pid": 32848, + "started": "2026-09-28T16:39:02.8650280Z" + }, + "armed": { + "runtime": "armed", + "state": "verified", + "native_task_id": "28279", + "native_task_pid": 29528, + "native_task_started": "2026-09-28T16:43:11.2748890Z", + "waiter_pid": 68644, + "waiter_started": "2026-09-28T16:43:11.7957230Z" + }, + "messages": [ + { + "seq": 3, + "from": "worker", + "to": "all", + "body_sha256": "6fb07d2a5f9fdbf1ea9786b4f9a77bed1f79f0a86a147da2f662cf1460d643c7", + "body_chars": 32, + "receipt": { + "state": "delivered", + "reason": "cursor_native_background_output", + "at": "2026-09-28T16:40:13.360Z", + "revision": 1 + }, + "audience_check": "all", + "worker_receipt": null + }, + { + "seq": 4, + "from": "worker", + "to": "bell", + "body_sha256": "cc22a0dc0bcafaf1d43e86c3e8b3ebc116d9658a6293af9db4dfceb0ea2fc339", + "body_chars": 34, + "receipt": { + "state": "delivered", + "reason": "cursor_native_background_output", + "at": "2026-09-28T16:41:12.706Z", + "revision": 1 + }, + "audience_check": "dm(bell)", + "worker_receipt": null + }, + { + "seq": 5, + "from": "worker", + "to": [ + "bell", + "worker" + ], + "body_sha256": "0344d6989a18b1d659e1a8b7b5f2340e6aae74ebfb5a64b3ed918290b9f7e162", + "body_chars": 46, + "receipt": { + "state": "delivered", + "reason": "cursor_native_background_output", + "at": "2026-09-28T16:42:21.028Z", + "revision": 1 + }, + "audience_check": "multi(bell,worker)", + "worker_receipt": "seat_unavailable" + } + ], + "stages": { + "initial_join": "observed", + "original_saved_and_receipt": "observed(delivered x3)", + "armed_native_task_pid_start": "observed", + "native_completion_same_cid": "observed", + "rearm_second_onward": "observed", + "tui_original": "observed" + }, + "hook_events": { + "total": 26, + "nonzero_exit": 0, + "stdin_prefix_hex_set": [ + "efbbbf7b" + ] + } + }, + { + "cell": "windows-grok", + "conversation_id": "01a0e8e6-e8e9-7663-8ae2-c296bb685cbb", + "endpoint_id": "d9d74c67-94ee-43bb-842e-0eb61a5c9820", + "caller_owner": { + "pid": 72544, + "started": "2026-09-28T16:43:59.6684400Z" + }, + "armed": { + "runtime": "armed", + "state": "verified", + "native_task_id": "01a0e8ee-0a72-7ad1-a40e-7c57f426a1d6", + "native_task_pid": 29740, + "native_task_started": "2026-09-28T16:51:49.4043170Z", + "waiter_pid": 52844, + "waiter_started": "2026-09-28T16:51:49.9638310Z" + }, + "messages": [ + { + "seq": 3, + "from": "worker", + "to": "all", + "body_sha256": "6fb07d2a5f9fdbf1ea9786b4f9a77bed1f79f0a86a147da2f662cf1460d643c7", + "body_chars": 32, + "receipt": { + "state": "delivered", + "reason": "parent_read_complete", + "at": "2026-09-28T16:49:20.432Z", + "revision": 1 + }, + "audience_check": "all", + "worker_receipt": null + }, + { + "seq": 4, + "from": "worker", + "to": "bell", + "body_sha256": "cc22a0dc0bcafaf1d43e86c3e8b3ebc116d9658a6293af9db4dfceb0ea2fc339", + "body_chars": 34, + "receipt": { + "state": "delivered", + "reason": "parent_read_complete", + "at": "2026-09-28T16:50:25.282Z", + "revision": 1 + }, + "audience_check": "dm(bell)", + "worker_receipt": null + }, + { + "seq": 5, + "from": "worker", + "to": [ + "bell", + "worker" + ], + "body_sha256": "0344d6989a18b1d659e1a8b7b5f2340e6aae74ebfb5a64b3ed918290b9f7e162", + "body_chars": 46, + "receipt": { + "state": "delivered", + "reason": "parent_read_complete", + "at": "2026-09-28T16:51:34.231Z", + "revision": 1 + }, + "audience_check": "multi(bell,worker)", + "worker_receipt": "seat_unavailable" + } + ], + "stages": { + "initial_join": "observed", + "original_saved_and_receipt": "observed(delivered x3)", + "armed_native_task_pid_start": "observed", + "native_completion_same_cid": "observed", + "rearm_second_onward": "observed", + "tui_original": "observed" + }, + "hook_events": { + "total": 42, + "nonzero_exit": 0, + "stdin_prefix_hex_set": [ + "7b22686f" + ] + } + } + ], + "cleanup": { + "fixture_processes_stopped": true, + "fixture_endpoints_remaining": 0, + "global_config_changed": false, + "known_unconsumed_context_remaining": 1, + "unattributed_windows_context_count": 3, + "unattributed_windows_join_result_count": 2 + } +} diff --git a/rag/parent-delivery/gui-environment-preparation.md b/rag/parent-delivery/gui-environment-preparation.md new file mode 100644 index 00000000..08435506 --- /dev/null +++ b/rag/parent-delivery/gui-environment-preparation.md @@ -0,0 +1,9 @@ +# GUI実機試験の環境準備 + +GUIの配送試験は未合格である。この記録は公式アプリと拡張の準備を示し、親へのnative配送の証拠には数えない。 + +LinuxとWindowsのCodex Appは正式なpackageと稼働processを確認した。Linuxはseat0のactiveなWayland session、Windowsはactiveなconsole sessionを確認している。SSHでDISPLAYが空でもGUI sessionの不存在とは判定しない。認証情報・個人の会話は読んでいない。 + +macOSのVS CodeとCodex拡張は導入済みである。不足していたLinuxのVS Code/Cursor Desktop、WindowsのVS Codeと両OSのCodex拡張を導入した。Linuxは[VS Code公式手順](https://code.visualstudio.com/docs/setup/linux)と[Cursor公式手順](https://cursor.com/docs/get-started/quickstart)のapt repositoryと署名keyを使い、WindowsはwingetのMicrosoft.VisualStudioCode user installerを使った。拡張は[VS Code公式CLI](https://code.visualstudio.com/docs/configure/command-line)のinstall-extensionでOpenAIのopenai.chatgptを導入した。拡張の用途は[OpenAI公式IDE案内](https://learn.chatgpt.com/docs/codex/ide)を参照する。 + +導入処理は既存のCodex設定・Peertableの接続・他のhookを編集していない。今回の導入ではGUIアプリを起動していない。残る観測は、対象アプリと拡張のサインイン、専用projectの実会話、背景配送後の同じ会話の継続と原文確認である。Jevの画面取得が停止しているため、GUI試験に限る操作ツール変更についてオーナーの回答を待っている。 diff --git a/rag/parent-delivery/room-image-preflight.json b/rag/parent-delivery/room-image-preflight.json new file mode 100644 index 00000000..a8e182e3 --- /dev/null +++ b/rag/parent-delivery/room-image-preflight.json @@ -0,0 +1,15 @@ +{ + "schema": "peertable.room-image-preflight.v1", + "source_commit": "a07630d2feae01945cc1a34dcd2247676c95ecd0", + "package_version": "0.8.63", + "image": "peertable-room:20260929-a07630d", + "image_digest": "sha256:e7453c06c5400388524cfb00ad46f46ffd62a911bbcaf76d6bd5033e1af8a9ab", + "platform": "linux/amd64", + "checks": { + "http_save_read_utf8": "passed", + "read_cors": "passed", + "sse_connect": "passed" + }, + "parent_native_acceptance": false, + "production_deployed": false +} From 3b10f50ac4b2c26c4389d3baa88d7f1dc335e1f0 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:15:53 +0900 Subject: [PATCH 12/72] =?UTF-8?q?=E6=9C=AC=E7=95=AAimage=E4=BD=9C=E6=88=90?= =?UTF-8?q?=E6=89=8B=E9=A0=86=E3=81=AE=E3=82=B3=E3=83=9E=E3=83=B3=E3=83=89?= =?UTF-8?q?=E3=82=92=E7=B5=B1=E4=B8=80=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index de61fbd2..27823abe 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -21,13 +21,13 @@ TAG=$(date +%Y%m%d)-$(git rev-parse --short HEAD) docker-buildx build --platform linux/amd64 --load -t peertable-room:$TAG room/ ``` -**`docker buildx`(CLI プラグイン経由)ではなく `docker-buildx`(standalone)を叩く。** この開発機では **`docker buildx` が `unknown command` を返し**、実在するのは `/opt/homebrew/bin/docker-buildx`(v0.36.0・Homebrew)だけ(2026-08-09 実測)。プラグイン配置を前提にすると新しい環境変更が要るので、**現ホストの正規入口をそのまま書く**。 +imageの作成には、Homebrewで導入したstandaloneの`docker-buildx`を使う。上のコマンドと同じ入口を、試験用image・本番用imageの双方で使う。 **`--load` を明示する。** 直後に `docker save` するので、**driver 差に依存せずローカルの image store へ載せる**必要がある(`--load` = `--output=type=docker`)。付けないと build は成功しても `docker save` で image が見つからない。 タグは `日付-短sha`(例 `20260809-4605744`)。**`latest` は使わない**——どの commit が本番に居るかが分からなくなる。 -**`docker build` で代用しない。`docker buildx build` を直接叩く。** Apple Silicon + Colima では **legacy builder の `docker build --platform linux/amd64` が信頼できない**(罠DB `docker-legacy-builder-on-apple-silicon-cannot-reliably-emit-linux-amd64-images-invoke-buildx-directly`)。「同じことだから」と書き換えたくなる場所なので明記する。 +Apple Siliconから本番向けimageを作る時は、上の`docker-buildx build --platform linux/amd64 --load`を使う。platformとimage storeへの読込みを、この一回のbuildで指定する。 roomのimageには`server.mjs`と、親の配送種別・healthを定義する`parent-kind.mjs`を含める。DockerfileのCOPY対象とserverのimportを照合する。親client・hook・spoolは利用端末のnpm packageが所有し、room imageへ載せない。 From 8196a4e23bee636c4954a120de1e87bddec42869 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:31:34 +0900 Subject: [PATCH 13/72] =?UTF-8?q?=E8=A6=AA=E9=85=8D=E9=80=81=E3=81=AE?= =?UTF-8?q?=E6=AD=A3=E5=BC=8F=E5=8F=97=E5=85=A5runner=E3=82=92=E9=80=9A?= =?UTF-8?q?=E5=B8=B8CLI=E3=81=AE=E5=AE=9F=E8=A6=AA=E3=81=A7=E5=8B=95?= =?UTF-8?q?=E3=81=8B=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm packの配布物をlocal prefixへ導入し、Aitermの公開PTYで起動した Claude/Codex通常CLIの実親からpeertable.parent-live-case.v1を取得する。 Aitermはstructured結果だけを読み、後片付けの未完了・gate自己監査の拒否・ transcriptの壊れた確定行はtyped failureにする。実装scenarioはaudienceのみ。 Co-Authored-By: Claude Opus 5.5 --- .../parent-product-acceptance/README.md | 53 ++++ .../parent-product-acceptance/aiterm.mjs | 64 ++++ .../parent-product-acceptance/evidence.mjs | 125 ++++++++ .../evidence.test.mjs | 126 ++++++++ .../parent-product-acceptance/harness.mjs | 120 ++++++++ experiments/parent-product-acceptance/run.mjs | 287 ++++++++++++++++++ 6 files changed, 775 insertions(+) create mode 100644 experiments/parent-product-acceptance/README.md create mode 100644 experiments/parent-product-acceptance/aiterm.mjs create mode 100644 experiments/parent-product-acceptance/evidence.mjs create mode 100644 experiments/parent-product-acceptance/evidence.test.mjs create mode 100644 experiments/parent-product-acceptance/harness.mjs create mode 100644 experiments/parent-product-acceptance/run.mjs diff --git a/experiments/parent-product-acceptance/README.md b/experiments/parent-product-acceptance/README.md new file mode 100644 index 00000000..9bbc5187 --- /dev/null +++ b/experiments/parent-product-acceptance/README.md @@ -0,0 +1,53 @@ +# 親配送の正式受入runner + +`scripts/parent-delivery-acceptance.mjs`が要求する`peertable.parent-live-case.v1`の証拠を、通常CLIの実親から取得する。対象はClaude Code/Codexの対話TUIだけで、Desktop/IDE、Cursor、Grokは扱わない。fixture、stream-json、app-serverの結果は証拠にしない。 + +## 実行 + +```sh +node experiments/parent-product-acceptance/run.mjs --harness claude|codex \ + --source <検証対象commit 40桁> --digest --version \ + [--scenarios audience] [--out ] [--model ] +``` + +runnerは1回の呼出しで次を連続して行う。 + +1. HEADが`--source`と一致し、room/skill/package/scriptsに未commit差分がないことを確認する。 +2. `npm pack`の正式tarballを`/prefix`へlocal導入し、導入物のruntime digest・package版・client版を指定値と照合する。global installとskill配置は行わない。 +3. 対象harnessのglobal設定をtarで退避し、導入物の`peertable connect --target `でPeertable所有entryだけを登録する。 +4. 配布物の`room/server.mjs`で試験専用roomを起動する。tokenは`PEERTABLE_TOKEN_SOURCE_FILE`経由で標準のseat-credential経路へ渡す。 +5. Aitermの公開MCP(`aiterm-mcp`)でPTYを開き、通常HOME・通常認証のまま`claude`/`codex`を起動する。Aitermの結果は宣言済みschemaのstructuredContentだけを読み、人間向けtextは解釈しない。`pty_open`は出力schemaを持たないため、指定したnameのsessionを`pty_observe`のstructured結果(`session_id`一致・`exists:true`)で確かめ、確かめられなければ`ACCEPTANCE_AITERM_SESSION_ID_MISSING`で止まる。試験dirの信頼dialogだけを肯定し、未知のdialogでは止まる。 +6. 実親に`parent_join`を1回呼ばせ、`verified`を待ってからscenarioを実測する。 +7. 後片付けを逆順で行う。harness終了、製品自身のendpoint停止の観測(30秒)、Codexの試験dir信頼entry削除、PTY close、room停止、`connect --remove`、設定の意味比較。各段の結果は`cleanupFailure`が完了条件と照合する。harness未終了、所有processの残存、endpoint未停止、pane・room processの残存、信頼entryの残存、connect解除の失敗、意味比較の不一致、Codex `config.toml`本文の不一致は、それぞれ原因code付きの`failed`になり、runは失敗で終わる。退避tarは`connect_remove`の完了条件をすべて満たした時だけ消す。製品が親終了後30秒以内にendpointを止めなかった場合と、停止済みendpointの索引が残った場合は、`findings`へ記録する。 + +Codexだけ、session層の`-c`を2つ足す。MCPへtoken参照先を渡す`mcp_servers.peertable_parent.env_vars`と、無人実行で更新dialogを出さない`check_for_update_on_startup=false`である。Codexのtrust dialogは`-c`では回避できないため、起動時に受諾する。書かれた`projects.<試験dir>`は、終了後に公式`config/batchWrite`で削除する。 + +## 出力 + +- `/public/rag/parent-delivery/live///cli/.json`: 公開投影の証拠。HOMEは`~`に置換し、credential/token系のkeyを落とす。 +- `/public/records.jsonl`: `product-acceptance.json`の`records`形式。親が受け入れる時に目録へ移す。 +- `/run-log.jsonl`、`/summary.json`: 進行・後片付け・自己監査。 +- `/private/`(0700): room data、token file、自sessionのtranscript複製、config比較。公開しない。 + +各caseは`evidence.mjs`がgateと同じ`auditAcceptance`で自己照合し、結果を`gate_errors`へ出す。gateが拒否した`passed`は目録へ出さず`ACCEPTANCE_SELF_AUDIT_FAILED`で止まる。観測が欠けたcheckは`failed`のまま目録形式で出し、runの成功には数えない。観測のないscenarioには成績を作らない(`ACCEPTANCE_NO_OBSERVATION`)。`summary.json`の`status`は、error・後片付けの失敗・`passed`以外のcaseが1つでもあれば`failed`になる。 + +transcript/rolloutは改行で終わった確定行だけを読む。確定行がJSONでなければ`ACCEPTANCE_TRANSCRIPT_CORRUPT`(行番号付き)で止まる。改行の無い末尾は追記中の未確定部分として`pending_tail`に数え、判定には使わない。最終判定の前に未確定末尾が消えるまで最大30秒待ち、消えなければtimeoutで止まる。 + +## 観測の出典 + +| 値 | 出典 | +| --- | --- | +| parent_session・実PID/開始identity | 製品spoolの`caller`(hookが実processから記録) | +| turn_id | Claude: transcriptの`promptId`。Codex: rolloutの`turn_context`/`task_started` | +| join相関 | Claude: `caller.use`と同じtool_use_idのturn。Codex: 同じ`endpoint_id`のjoin結果を返したtool出力のturn | +| 配送本文 | 会話へ入ったentryだけ。Claude: `promptId`付きuser entry(hookの`blocking error`表示)。`queue-operation`はnative queue証拠として別記録する。Codex: `response_item`のuser message | +| receipt | room `GET deliveries?seq=`(result、reason、receipt_revision、queued_submission_id、accepted_at) | +| native ACK | Claude: 配送user entryのuuid。Codex: 公式queueの受付ID | + +Codexは`Stop`/`PostToolUse` hookの出力を``要素へ入れ、本文をXML文字参照で符号化する。runnerはその要素の中身だけを定義済み実体と数値参照に限って復号し、照合する。証拠には`boundary.encoding`と`raw_body_equal`を残す。queue経由のidle配送は符号化されない。 + +## 実装済みscenario + +`audience`(DM・親を含む複数宛・all、各1回、room/from/to/seq/本文一致、同会話の後続返答)だけを実装している。他の24 scenarioを指定すると`ACCEPTANCE_SCENARIO_NOT_IMPLEMENTED`で止まる。代替結果で埋めない。 + +focused test: `node --test experiments/parent-product-acceptance/evidence.test.mjs` diff --git a/experiments/parent-product-acceptance/aiterm.mjs b/experiments/parent-product-acceptance/aiterm.mjs new file mode 100644 index 00000000..8a7eccac --- /dev/null +++ b/experiments/parent-product-acceptance/aiterm.mjs @@ -0,0 +1,64 @@ +// 通常CLIの対話PTYはAitermの公開MCP(stdio)だけで開く。tmux/psmuxへ直接依存しない。 +// 結果は公式のstructuredContentだけを読み、人間向けtextは解釈しない。 +import { spawn } from 'node:child_process' +import { createInterface } from 'node:readline' + +const typed = (code, message) => Object.assign(new Error(message), { code }) + +export async function openAiterm({ executable = process.env.PEERTABLE_ACCEPTANCE_AITERM ?? 'aiterm-mcp', args = [] } = {}) { + const child = spawn(executable, args, { stdio: ['pipe', 'pipe', 'pipe'], shell: process.platform === 'win32' }) + const pending = new Map() + let seq = 0, stderr = '' + child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-4000) }) + const fail = error => { for (const item of pending.values()) item.reject(error); pending.clear() } + child.on('error', fail) + child.on('exit', code => fail(typed('ACCEPTANCE_AITERM_EXITED', `aiterm-mcpが終了しました code=${code} ${stderr}`))) + createInterface({ input: child.stdout }).on('line', line => { + let row + try { row = JSON.parse(line) } catch { return fail(typed('ACCEPTANCE_AITERM_PROTOCOL', `aiterm-mcpのstdoutがJSON-RPCではありません: ${line.slice(0, 200)}`)) } + const item = pending.get(row.id) + if (!item) return + pending.delete(row.id) + row.error ? item.reject(typed('ACCEPTANCE_AITERM_RPC', JSON.stringify(row.error))) : item.resolve(row.result) + }) + const request = (method, params) => new Promise((resolve, reject) => { + const id = ++seq + pending.set(id, { resolve, reject }) + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n') + }) + await request('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'peertable_parent_acceptance', version: '1' } }) + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n') + const call = async (name, args) => { + const result = await request('tools/call', { name, arguments: args }) + const text = (result.content ?? []).filter(part => part.type === 'text').map(part => part.text).join('\n') + if (result.isError) throw typed('ACCEPTANCE_AITERM_TOOL', `${name}: ${text}`) + return result.structuredContent ?? null + } + // 宣言済みschemaを持つtoolは、そのschema名のstructuredContentが無ければtyped errorにする。 + const structured = async (name, args, schema) => { + const value = await call(name, args) + if (value?.schema !== schema) throw typed('ACCEPTANCE_AITERM_STRUCTURED_MISSING', `${name}が${schema}を返しません`) + return value + } + const observe = session_id => structured('pty_observe', { session_id }, 'aiterm.pty-observe-result.v1') + return { + call, + observe, + // pty_openは出力schemaを宣言していない。指定したnameのsessionを公式pty_observeのstructured結果で確認する。 + async open(name, shell) { + await call('pty_open', { name, ...(shell ? { shell } : {}) }) + const seen = await observe(name) + if (seen.session_id !== name || seen.exists !== true) throw typed('ACCEPTANCE_AITERM_SESSION_ID_MISSING', `pty_open後にsession ${name} をpty_observeで確認できません`) + return name + }, + send: (session_id, text, enter = true) => call('pty_send', { session_id, text, enter }), + key: (session_id, key) => call('pty_key', { session_id, key }), + screen: async session_id => (await structured('pty_read', { session_id, screen: true, raw: true }, 'aiterm.pty-read-result.v1')).text, + async close(session_id) { + const closed = await structured('pty_close', { session_id }, 'aiterm.pty-close-result.v1') + if (closed.session_id !== session_id || !['closed', 'already_closed'].includes(closed.outcome)) throw typed('ACCEPTANCE_AITERM_CLOSE', `pty_closeの結果が不正です: ${JSON.stringify(closed)}`) + return closed + }, + async end() { child.stdin.end(); await new Promise(resolve => { const timer = setTimeout(() => { child.kill(); resolve() }, 3000); child.once('exit', () => { clearTimeout(timer); resolve() }) }) }, + } +} diff --git a/experiments/parent-product-acceptance/evidence.mjs b/experiments/parent-product-acceptance/evidence.mjs new file mode 100644 index 00000000..2f6131e3 --- /dev/null +++ b/experiments/parent-product-acceptance/evidence.mjs @@ -0,0 +1,125 @@ +// 実境界で観測した値だけから peertable.parent-live-case.v1 と目録recordを組み立てる。 +// 観測が欠けたcaseへpassedを書かない。成否はgateと同じ関数で自己照合する。 +import { createHash } from 'node:crypto' +import { homedir } from 'node:os' +import { auditAcceptance, expectedCases } from '../../scripts/parent-delivery-acceptance.mjs' + +export const sha256 = value => createHash('sha256').update(value).digest('hex') + +// renderDelivery の出力を実境界の本文から逆に読む。本文中の改行・角括弧は末尾の配送ID行で区切る。 +export function parseDelivered(text) { + const out = [] + const head = /\[Peertable room=(\S+) from=(\S+) to=(.+?) seq=(\S+)\]\n本文: /gu + let match + while ((match = head.exec(text))) { + const start = match.index + match[0].length + const tail = /\n\[配送ID=([0-9a-f-]{36}) digest=([0-9a-f]{64})( 全文取得=parent_read)?\]/gu + tail.lastIndex = start + const end = tail.exec(text) + if (!end) continue + let to + try { to = JSON.parse(match[3]) } catch { to = match[3] } + out.push({ room: match[1], from: match[2], to, seq: Number(match[4]), body: text.slice(start, end.index), delivery_id: end[1], digest: end[2], preview: Boolean(end[3]) }) + head.lastIndex = end.index + end[0].length + } + return out +} + +// 公開投影はHOMEと秘密keyを落とす。本文・ID・seqは照合に必要なので残す。 +export function project(value) { + const home = homedir() + if (Array.isArray(value)) return value.map(project) + if (typeof value === 'string') return value.split(home).join('~') + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value) + .filter(([key]) => !['credential', 'continuation_key', 'token', 'auth', 'apiKeySource', 'env'].includes(key)) + .map(([key, item]) => [key, project(item)])) + return value +} + +const failed = (reasons, extra = {}) => ({ status: reasons.length ? 'failed' : 'passed', reasons, ...extra }) + +// 1 audience分の照合。original=room投稿、received=親harness記録から読んだ実配送本文。 +export function judgeAudience({ label, posted, record, receipt, deliveries, replies, session, recipient }) { + const reasons = [] + const original = { room: posted.room, from: posted.from, to: posted.to_names ?? posted.to, seq: posted.seq, body: posted.body } + const matches = deliveries.filter(item => item.delivery_id === record?.delivery_id) + if (!record) reasons.push('SPOOL_RECORD_MISSING') + else if (record.state !== 'submitted') reasons.push(`SPOOL_STATE_${record.state}`) + if (record && record.event?.body !== posted.body) reasons.push('SPOOL_BODY_MISMATCH') + if (matches.length !== 1) reasons.push(`HARNESS_DELIVERY_COUNT_${matches.length}`) + const received = matches[0] ? { room: matches[0].room, from: matches[0].from, to: matches[0].to, seq: matches[0].seq, body: matches[0].body } : null + const bodyEqual = Boolean(received) && JSON.stringify(original) === JSON.stringify(received) + if (received && !bodyEqual) reasons.push('HARNESS_BODY_MISMATCH') + if (matches[0]?.session !== session) reasons.push('HARNESS_SESSION_MISMATCH') + const reply = replies.find(item => item.session === session && item.text.includes(posted.nonce) && item.order > (matches[0]?.order ?? Infinity)) + if (!reply) reasons.push('ASSISTANT_REPLY_MISSING') + if (receipt?.state !== 'delivered') reasons.push(`ROOM_RECEIPT_${receipt?.state ?? 'missing'}`) + if (!Number.isSafeInteger(receipt?.receipt_revision)) reasons.push('ROOM_RECEIPT_REVISION_MISSING') + return failed(reasons, { + label, recipient, original, received, body_equal: bodyEqual, count: matches.length, + delivery: matches[0] ?? null, reply: reply ?? null, spool: record ? { delivery_id: record.delivery_id, state: record.state, queued_submission_id: record.queued_submission_id, accepted_at: record.accepted_at, reason: record.receipt?.reason ?? null } : null, + receipt: receipt ? { seq: posted.seq, recipient, route: 'parent_receiver', result: receipt.state, reason: receipt.reason ?? null, receipt_revision: receipt.receipt_revision ?? null, queued_submission_id: receipt.queued_submission_id ?? null, accepted_at: receipt.accepted_at ?? null } : null, + }) +} + +export function caseId({ os, harness, surface, scenario }) { return `${os}/${harness}/${surface}/${scenario}` } + +// case証拠。checksのどれかが失敗なら status:failed、観測なしなら証拠自体を作らない。 +export function buildCase({ meta, scenario, checks, observations, extra = {} }) { + if (!checks.length) throw Object.assign(new Error('観測のないscenarioに成績を作りません'), { code: 'ACCEPTANCE_NO_OBSERVATION' }) + const id = caseId({ ...meta, scenario }) + const reasons = checks.flatMap(check => check.reasons.map(reason => `${check.label}:${reason}`)) + const evidence = { + schema: 'peertable.parent-live-case.v1', case_id: id, status: reasons.length ? 'failed' : 'passed', reasons, + native: true, os: meta.os, harness: meta.harness, surface: meta.surface, + source_commit: meta.source_commit, runtime_digest: meta.runtime_digest, package_version: meta.package_version, client_version: meta.client_version, + package_tarball_sha256: meta.package_tarball_sha256, harness_version: meta.harness_version, node_version: meta.node_version, + parent_session: meta.parent_session, parent_process: meta.parent_process, endpoint_id: meta.endpoint_id, room: meta.room, + owner_input_required: false, isolation: meta.isolation, + observations, receipts: checks.map(check => check.receipt).filter(Boolean), + body_checks: checks.filter(check => check.received).map(check => ({ label: check.label, original: check.original, received: check.received })), + checks, ...extra, + } + return project(evidence) +} + +export function buildRecord(evidence, evidenceFile, extra = {}) { + return { + id: evidence.case_id, status: evidence.status, kind: 'product_live', native: true, + harness_version: evidence.harness_version, source_commit: evidence.source_commit, runtime_digest: evidence.runtime_digest, + package_version: evidence.package_version, parent_session: evidence.parent_session, evidence_file: evidenceFile, + body_equal: evidence.body_checks.length > 0 && evidence.checks.every(check => check.body_equal !== false), + owner_input_required: false, receipt_evidence: evidence.receipts.map(receipt => `${receipt.seq}:${receipt.recipient}:r${receipt.receipt_revision}`).join(','), + observations: evidence.observations.map(item => item.kind), ...extra, + } +} + +const typed = (code, message) => Object.assign(new Error(message), { code }) + +// 後片付けの結果を完了条件と照合する。未完了は成功へ丸めず、原因codeを返す。未知のlabelは判定できないのでtyped errorにする。 +export function cleanupFailure(label, result) { + const rules = { + harness_exit: [[result.exited !== true, 'ACCEPTANCE_HARNESS_NOT_EXITED']], + endpoint_stop: [[!Array.isArray(result.owned_alive_after) || result.owned_alive_after.length > 0, 'ACCEPTANCE_OWNED_PROCESS_ALIVE'], [result.runtime_after !== 'stopped', 'ACCEPTANCE_ENDPOINT_NOT_STOPPED']], + codex_folder_trust_remove: [[result.remaining_trust_entries !== 0, 'ACCEPTANCE_TRUST_ENTRY_LEFT']], + pty_close: [[result.pane_alive_after !== false, 'ACCEPTANCE_PANE_ALIVE'], [!['closed', 'already_closed'].includes(result.outcome), 'ACCEPTANCE_PTY_NOT_CLOSED']], + room_server: [[result.alive_after !== false, 'ACCEPTANCE_ROOM_ALIVE']], + connect_remove: [[!result.removed?.length || result.removed.some(item => !item.endsWith(':removed')), 'ACCEPTANCE_CONNECT_REMOVE_FAILED'], [result.semantic_equal !== true, 'ACCEPTANCE_CONFIG_NOT_RESTORED'], [result.config_text_equal === false, 'ACCEPTANCE_CONFIG_TEXT_CHANGED']], + }[label] + if (!rules) throw typed('ACCEPTANCE_CLEANUP_UNKNOWN', `完了条件の無い後片付けです: ${label}`) + const codes = rules.filter(([broken]) => broken).map(([, code]) => code) + return codes.length ? { code: codes[0], codes } : null +} + +// 自己監査でgateが拒否したpassedは目録へ出さずtyped errorで止める。failedはそのまま記録し、runの成功には数えない。 +export function acceptCase(record, audit) { + if (record.status === 'passed' && audit.gate_errors.length) throw typed('ACCEPTANCE_SELF_AUDIT_FAILED', `${record.id}: ${JSON.stringify(audit.gate_errors)}`) + return { run_ok: record.status === 'passed' } +} + +// 自分の1件だけをgateの関数へ通し、MISSING以外のerrorが無いかを見る。 +export function selfAudit(record, evidence) { + const audit = auditAcceptance([record], { readEvidence: () => evidence, sourceCommit: record.source_commit, sourceDigest: record.runtime_digest, packageVersion: record.package_version }) + const own = audit.errors.filter(error => error.id === record.id) + return { id: record.id, gate_errors: own, expected_total: expectedCases().length } +} diff --git a/experiments/parent-product-acceptance/evidence.test.mjs b/experiments/parent-product-acceptance/evidence.test.mjs new file mode 100644 index 00000000..94c5135c --- /dev/null +++ b/experiments/parent-product-acceptance/evidence.test.mjs @@ -0,0 +1,126 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { parseDelivered, judgeAudience, buildCase, buildRecord, selfAudit, acceptCase, cleanupFailure } from './evidence.mjs' +import { readJsonl, readTranscript } from './harness.mjs' +import { openAiterm } from './aiterm.mjs' +import { renderDelivery } from '../../skill/scripts/parent-delivery.mjs' + +const scratch = t => { const dir = mkdtempSync(join(tmpdir(), 'ptacc-test-')); t.after(() => rmSync(dir, { recursive: true, force: true })); return dir } + +const state = { room: 'r1', name: 'bell' } +const body = '日本語\n「引用」😀\n[配送ID=偽] 符号 N1' +const record = { delivery_id: '11111111-2222-3333-4444-555555555555', digest: 'a'.repeat(64), state: 'submitted', event: { seq: 7, from: 'probe', to: 'bell', body }, receipt: { reason: 'x' } } +const rendered = renderDelivery(state, record) + +test('製品の描画から本文・宛先・seqを欠けなく読み戻す', () => { + const [parsed] = parseDelivered(`前置き\n${rendered}\n後置き`) + assert.deepEqual({ room: parsed.room, from: parsed.from, to: parsed.to, seq: parsed.seq, body: parsed.body, delivery_id: parsed.delivery_id }, { room: 'r1', from: 'probe', to: 'bell', seq: 7, body, delivery_id: record.delivery_id }) +}) + +const posted = { room: 'r1', from: 'probe', to: 'bell', seq: 7, body, nonce: 'N1' } +const base = { label: 'dm', posted, record, receipt: { state: 'delivered', receipt_revision: 2 }, session: 's1', recipient: 'bell' } +const delivered = parseDelivered(rendered).map(item => ({ ...item, order: 3, session: 's1', turn_id: 't1' })) +const replies = [{ order: 4, session: 's1', turn_id: 't1', text: '符号 N1' }] + +test('実境界で1回・原文一致・後続返答・receiptが揃った時だけpassed', () => { + assert.equal(judgeAudience({ ...base, deliveries: delivered, replies }).status, 'passed') + assert.deepEqual(judgeAudience({ ...base, deliveries: [...delivered, ...delivered], replies }).reasons, ['HARNESS_DELIVERY_COUNT_2']) + assert.ok(judgeAudience({ ...base, deliveries: [], replies }).reasons.includes('HARNESS_DELIVERY_COUNT_0')) + assert.ok(judgeAudience({ ...base, deliveries: delivered, replies: [{ ...replies[0], order: 1 }] }).reasons.includes('ASSISTANT_REPLY_MISSING')) + assert.ok(judgeAudience({ ...base, deliveries: delivered.map(item => ({ ...item, body: item.body + 'x' })), replies }).reasons.includes('HARNESS_BODY_MISMATCH')) + assert.ok(judgeAudience({ ...base, receipt: { state: 'unknown', receipt_revision: 3 }, deliveries: delivered, replies }).reasons.includes('ROOM_RECEIPT_unknown')) +}) + +const meta = { os: 'darwin', harness: 'claude', surface: 'cli', source_commit: 'b'.repeat(40), runtime_digest: 'c'.repeat(64), package_version: '0.8.63', harness_version: '2.1.283', parent_session: 's1' } +test('証拠はgateの形式を満たし、失敗checkはfailedのまま目録へ出る', () => { + const check = judgeAudience({ ...base, deliveries: delivered, replies }) + const observations = [{ kind: 'delivery_in_parent_conversation', parent_session: 's1', turn_id: 't1' }] + const evidence = buildCase({ meta, scenario: 'audience', checks: [check], observations }) + const record = buildRecord(evidence, 'x.json', { audiences: { dm: { count: 1, body_equal: true }, multiple: { count: 1, body_equal: true }, all: { count: 1, body_equal: true } } }) + assert.deepEqual(selfAudit(record, evidence).gate_errors, []) + const bad = buildCase({ meta, scenario: 'audience', checks: [judgeAudience({ ...base, deliveries: [], replies })], observations }) + assert.equal(buildRecord(bad, 'x.json').status, 'failed') + assert.ok(selfAudit(buildRecord(bad, 'x.json'), bad).gate_errors.some(error => error.code === 'PARENT_ACCEPTANCE_INVALID')) + assert.throws(() => buildCase({ meta, scenario: 'audience', checks: [], observations }), { code: 'ACCEPTANCE_NO_OBSERVATION' }) +}) + +test('Codexのhook_prompt要素はXML文字参照だけを復号し、生textとの差を残す', async t => { + const { readTranscript, decodeXmlText } = await import('./harness.mjs') + const special = 'x < y & "z" \'q\' 字面>\n二行目', shown = renderDelivery(state, { ...record, event: { ...record.event, body: special } }) + const escaped = shown.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"') + const dir = scratch(t), file = join(dir, 'rollout.jsonl') + const row = text => JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text }] } }) + writeFileSync(file, [JSON.stringify({ type: 'turn_context', payload: { turn_id: 't9' } }), row(`${escaped}`), row(shown)].join('\n') + '\n') + const seen = readTranscript('codex', file) + assert.deepEqual(seen.injections.map(item => [item.hook, item.encoding, item.turn_id]), [['stop', 'codex_hook_prompt_xml_text', 't9'], [null, 'none', 't9']]) + assert.equal(parseDelivered(seen.injections[0].text)[0].body, special) + assert.notEqual(parseDelivered(seen.injections[0].raw_text)[0].body, special) + assert.equal(parseDelivered(seen.injections[1].text)[0].body, special) + assert.equal(decodeXmlText('&gt; &unknown; 😀'), '> &unknown; 😀') +}) + +test('確定行の不正JSONは止め、改行の無い末尾は未確定として読まない', t => { + const good = JSON.stringify({ type: 'x' }) + assert.deepEqual(readJsonl(`${good}\n${good.slice(0, 5)}`, 'f'), { rows: [{ order: 0, row: { type: 'x' } }], pending_tail: 5 }) + assert.equal(readJsonl(`${good}\n`, 'f').pending_tail, 0) + assert.throws(() => readJsonl(`${good}\n{broken\n${good}\n`, 'f'), { code: 'ACCEPTANCE_TRANSCRIPT_CORRUPT', line: 2 }) + const file = join(scratch(t), 'session.jsonl') + writeFileSync(file, `${good}\n{"type":"user","mess`) + assert.equal(readTranscript('claude', file).pending_tail, 20) +}) + +test('後片付けの未完了は成功へ丸めず原因codeを返す', () => { + assert.equal(cleanupFailure('harness_exit', { exited: true }), null) + assert.equal(cleanupFailure('harness_exit', { exited: false }).code, 'ACCEPTANCE_HARNESS_NOT_EXITED') + assert.equal(cleanupFailure('endpoint_stop', { owned_alive_after: [], runtime_after: 'stopped' }), null) + assert.equal(cleanupFailure('endpoint_stop', { owned_alive_after: [42], runtime_after: 'stopped' }).code, 'ACCEPTANCE_OWNED_PROCESS_ALIVE') + assert.equal(cleanupFailure('endpoint_stop', { owned_alive_after: [], runtime_after: 'armed' }).code, 'ACCEPTANCE_ENDPOINT_NOT_STOPPED') + assert.equal(cleanupFailure('pty_close', { outcome: 'closed', pane_alive_after: null }).code, 'ACCEPTANCE_PANE_ALIVE') + assert.equal(cleanupFailure('room_server', { alive_after: true }).code, 'ACCEPTANCE_ROOM_ALIVE') + assert.equal(cleanupFailure('codex_folder_trust_remove', { remaining_trust_entries: 1 }).code, 'ACCEPTANCE_TRUST_ENTRY_LEFT') + assert.equal(cleanupFailure('connect_remove', { removed: ['codex:removed'], semantic_equal: true, config_text_equal: true }), null) + assert.equal(cleanupFailure('connect_remove', { removed: ['codex:removed'], semantic_equal: true, config_text_equal: false }).code, 'ACCEPTANCE_CONFIG_TEXT_CHANGED') + assert.equal(cleanupFailure('connect_remove', { removed: ['claude:failed'], semantic_equal: true }).code, 'ACCEPTANCE_CONNECT_REMOVE_FAILED') + assert.throws(() => cleanupFailure('unknown_step', {}), { code: 'ACCEPTANCE_CLEANUP_UNKNOWN' }) +}) + +test('gateが拒否したpassedは目録へ出さず、failedはrun成功に数えない', () => { + const record = { id: 'darwin/claude/cli/audience', status: 'passed' } + assert.throws(() => acceptCase(record, { gate_errors: [{ code: 'PARENT_ACCEPTANCE_EVIDENCE_INVALID' }] }), { code: 'ACCEPTANCE_SELF_AUDIT_FAILED' }) + assert.deepEqual(acceptCase(record, { gate_errors: [] }), { run_ok: true }) + assert.deepEqual(acceptCase({ ...record, status: 'failed' }, { gate_errors: [{ code: 'PARENT_ACCEPTANCE_INVALID' }] }), { run_ok: false }) +}) + +// 実aitermの代わりに、MCP stdio境界で既知の応答を返す小さなserverを使う。人間向けtextは解釈しないことを確かめる。 +test('Aitermはstructured結果だけを使い、欠落はtyped errorにする', async t => { + const dir = scratch(t), server = join(dir, 'fake-aiterm.mjs') + writeFileSync(server, `import { createInterface } from 'node:readline' +const mode = process.env.FAKE_MODE +const send = row => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...row }) + '\\n') +createInterface({ input: process.stdin }).on('line', line => { + const row = JSON.parse(line) + if (row.id === undefined) return + if (row.method === 'initialize') return send({ id: row.id, result: {} }) + const { name, arguments: args } = row.params + if (name === 'pty_open') return send({ id: row.id, result: { content: [{ type: 'text', text: 'session_id: ' + args.name }] } }) + if (name === 'pty_observe') return send({ id: row.id, result: { content: [{ type: 'text', text: '{}' }], ...(mode === 'text_only' ? {} : { structuredContent: { schema: 'aiterm.pty-observe-result.v1', session_id: args.session_id, exists: mode !== 'missing' } }) } }) + if (name === 'pty_close') return send({ id: row.id, result: { content: [{ type: 'text', text: 'closed' }], structuredContent: { schema: 'aiterm.pty-close-result.v1', session_id: args.session_id, outcome: 'closed' } } }) +})`) + const run = async mode => { + process.env.FAKE_MODE = mode + const aiterm = await openAiterm({ executable: process.execPath, args: [server] }) + try { return await aiterm.open('s1') } finally { await aiterm.end() } + } + t.after(() => { delete process.env.FAKE_MODE }) + assert.equal(await run('ok'), 's1') + await assert.rejects(run('text_only'), { code: 'ACCEPTANCE_AITERM_STRUCTURED_MISSING' }) + await assert.rejects(run('missing'), { code: 'ACCEPTANCE_AITERM_SESSION_ID_MISSING' }) + process.env.FAKE_MODE = 'ok' + const aiterm = await openAiterm({ executable: process.execPath, args: [server] }) + assert.deepEqual(await aiterm.close('s1'), { schema: 'aiterm.pty-close-result.v1', session_id: 's1', outcome: 'closed' }) + await aiterm.end() + assert.ok(existsSync(dir)) +}) diff --git a/experiments/parent-product-acceptance/harness.mjs b/experiments/parent-product-acceptance/harness.mjs new file mode 100644 index 00000000..9b6113df --- /dev/null +++ b/experiments/parent-product-acceptance/harness.mjs @@ -0,0 +1,120 @@ +// 通常CLI各harnessの起動・起動時dialog・自sessionの記録fileの差をここへ閉じ込める。 +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { execFileSync } from 'node:child_process' + +const posixQuote = value => `'${String(value).replaceAll("'", `'\\''`)}'` +const psQuote = value => `'${String(value).replaceAll("'", "''")}'` + +export function resolveCli(harness) { + const name = { claude: 'claude', codex: 'codex' }[harness] + if (!name) throw Object.assign(new Error(`未対応のharnessです: ${harness}`), { code: 'ACCEPTANCE_HARNESS_UNSUPPORTED' }) + const found = execFileSync(process.platform === 'win32' ? 'where.exe' : 'which', [name], { encoding: 'utf8' }).trim().split(/\r?\n/u)[0] + const version = execFileSync(found, ['--version'], { encoding: 'utf8', shell: process.platform === 'win32' }).trim().split(/\r?\n/u)[0] + return { executable: found, version } +} + +// 通常起動のargvへ足すのは、試験roomのtoken参照先をMCPへ渡す指定と、無人実行でCLI更新を促さない指定だけ(session層)。 +export function launchLine(harness, { project, tokenFile, model }) { + const extra = harness === 'codex' ? ['-c', 'mcp_servers.peertable_parent.env_vars=["PEERTABLE_TOKEN_SOURCE_FILE"]', '-c', 'check_for_update_on_startup=false'] : [] + if (model) extra.push(harness === 'codex' ? '-m' : '--model', model) + if (process.platform === 'win32') return `Set-Location -LiteralPath ${psQuote(project)}; $env:PEERTABLE_TOKEN_SOURCE_FILE = ${psQuote(tokenFile)}; & ${harness} ${extra.map(psQuote).join(' ')}` + return `cd ${posixQuote(project)} && PEERTABLE_TOKEN_SOURCE_FILE=${posixQuote(tokenFile)} exec ${harness} ${extra.map(posixQuote).join(' ')}` +} +export const isolation = harness => ({ + home: 'normal', auth: 'normal', room: 'local_private_room_server', token: 'PEERTABLE_TOKEN_SOURCE_FILE→project .team/credentials (標準seat-credential経路)', + session_args: harness === 'codex' ? ['-c mcp_servers.peertable_parent.env_vars=["PEERTABLE_TOKEN_SOURCE_FILE"]', '-c check_for_update_on_startup=false'] : [], + folder_trust: harness === 'codex' ? '起動dialogで試験dirを信頼し、終了後に公式config/batchWriteでその projects entry だけを削除' : '起動dialogで試験dirを信頼(Claudeのproject履歴に残る)', +}) + +// 起動直後の公式dialogへ、試験dirを信頼する操作だけを返す。未知のdialogは推測で押さずtimeoutで止める。 +export function startupAction(harness, screen) { + if (harness === 'claude') { + // 既定選択は「No, exit」。明示的に「Yes, I trust this folder」へ移して確定する。 + if (/Quick safety check|Yes, I trust this folder/u.test(screen)) return { keys: ['Down', 'Enter'], reason: 'claude_folder_trust' } + if (/Claude Code v\d/u.test(screen) && /^\s*❯\s/mu.test(screen)) return { ready: true } + return null + } + if (/Update available/u.test(screen)) return { blocked: 'codex_update_prompt' } + if (/Do you trust the contents of this directory/u.test(screen) && /›\s*1\. Yes, continue/u.test(screen)) return { keys: ['Enter'], reason: 'codex_directory_trust' } + if (/OpenAI Codex \(v/u.test(screen) && /^\s*›\s/mu.test(screen) && !/Press enter to continue/u.test(screen)) return { ready: true } + return null +} + +// 自sessionの記録fileだけを名前で特定する。他会話の中身は読まない。 +export function transcriptPath(harness, session) { + if (harness === 'claude') { + const root = join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'), 'projects') + for (const dir of readdirSync(root)) { const file = join(root, dir, `${session}.jsonl`); if (existsSync(file)) return file } + return null + } + const root = join(process.env.CODEX_HOME ?? join(homedir(), '.codex'), 'sessions') + const walk = dir => { + for (const name of readdirSync(dir).sort().reverse()) { + const path = join(dir, name) + if (statSync(path).isDirectory()) { const hit = walk(path); if (hit) return hit } + else if (name.startsWith('rollout-') && name.endsWith(`${session}.jsonl`)) return path + } + return null + } + return existsSync(root) ? walk(root) : null +} + +// XML 1.0の定義済み実体と数値文字参照だけを復号する。それ以外の & は符号化違反としてそのまま残し、照合で不一致にする。 +export const decodeXmlText = text => text.replace(/&(lt|gt|amp|quot|apos|#\d+|#x[0-9a-fA-F]+);/gu, (_, name) => name[0] === '#' ? String.fromCodePoint(name[1] === 'x' ? parseInt(name.slice(2), 16) : Number(name.slice(1))) : { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" }[name]) +const textOf = content => typeof content === 'string' ? content : Array.isArray(content) ? content.map(part => part.text ?? part.content ?? (typeof part === 'string' ? part : '')).map(textOf).join('') : content && typeof content === 'object' ? textOf(content.text ?? content.content ?? '') : '' + +// JSONLを行順に読む。改行で終わった行は確定行で、JSONでなければtyped errorで止める。 +// 改行の無い末尾はharnessが追記中の未確定部分なので読まず、pending_tailとして返す。 +export function readJsonl(text, file) { + const lines = text.split('\n'), tail = lines.pop() + const rows = lines.map((line, order) => { + try { return { order, row: JSON.parse(line) } } + catch { throw Object.assign(new Error(`${file}:${order + 1} が確定行なのにJSONではありません`), { code: 'ACCEPTANCE_TRANSCRIPT_CORRUPT', line: order + 1 }) } + }) + return { rows, pending_tail: tail.length } +} + +// 記録を行順に読み、親へ届いた本文(injections)と親の返答(replies)をturn識別子付きで返す。 +export function readTranscript(harness, file) { + const { rows, pending_tail } = readJsonl(readFileSync(file, 'utf8'), file) + const injections = [], replies = [], turns = [], queued = [], toolUses = [] + let turn = null + for (const { order, row } of rows) { + if (harness === 'claude') { + // asyncRewakeのexit2出力は queue-operation(enqueue) を経て、promptId付きのuser entryとして会話へ入る。 + if (row.type === 'queue-operation') { if (String(row.content ?? '').includes('[Peertable room=')) queued.push({ order, operation: row.operation ?? null, at: row.timestamp ?? null, text: row.content }); continue } + if (row.type === 'user' && !row.isMeta && !row.toolUseResult) { + turn = row.promptId ?? null + if (turn && !turns.includes(turn)) turns.push(turn) + const text = textOf(row.message?.content ?? '') + if (text.includes('[Peertable room=')) injections.push({ order, session: row.sessionId, turn_id: row.promptId ?? null, entry_uuid: row.uuid, entry_type: 'user', hook: /hook blocking error from command "([^"]+)"/u.exec(text)?.[1] ?? null, text }) + } + if (row.type === 'assistant') { + const parts = row.message?.content ?? [] + for (const part of parts) if (part.type === 'tool_use') toolUses.push({ order, session: row.sessionId, turn_id: turn, id: part.id, name: part.name }) + const said = textOf(parts.filter(part => part.type === 'text')) + if (said) replies.push({ order, session: row.sessionId, turn_id: turn, entry_uuid: row.uuid, text: said }) + } + continue + } + // Codex rollout: turnはtask_started/turn_context。会話へ入った入力と返答は response_item の message だけを数える。 + // Stop/PostToolUse hookの本文は で包まれ、公式queue由来のidle配送は包まれない。 + const payload = row.payload ?? {} + if (row.type === 'turn_context' || (row.type === 'event_msg' && ['task_started', 'turn_started'].includes(payload.type))) { turn = payload.turn_id ?? turn; if (turn && !turns.includes(turn)) turns.push(turn) } + if (row.type !== 'response_item') continue + if (payload.type === 'message' && payload.role === 'user') { + const raw = textOf(payload.content) + if (!raw.includes('[Peertable room=')) continue + // Codexはhook出力を 要素へ入れ、本文をXML文字参照で符号化する。要素の中身だけを復号し、生textも残す。 + const wrapped = /^([\s\S]*)<\/hook_prompt>$/u.exec(raw) + const text = wrapped ? decodeXmlText(wrapped[2]) : raw + injections.push({ order, session: null, turn_id: turn, entry_type: 'response_item:message:user', role: 'user', hook: wrapped ? wrapped[1].split(':')[0] : null, encoding: wrapped ? 'codex_hook_prompt_xml_text' : 'none', raw_text: raw, text }) + } + if (payload.type === 'message' && payload.role === 'assistant') replies.push({ order, session: null, turn_id: turn, text: textOf(payload.content) }) + if (['function_call', 'custom_tool_call'].includes(payload.type)) toolUses.push({ order, session: null, turn_id: turn, id: payload.call_id ?? null, name: payload.name ?? null }) + if (['function_call_output', 'custom_tool_call_output'].includes(payload.type)) toolUses.push({ order, session: null, turn_id: turn, id: payload.call_id ?? null, name: 'output', output: textOf(payload.output ?? '') }) + } + return { injections, replies, turns, queued, toolUses, rows: rows.length, pending_tail } +} diff --git a/experiments/parent-product-acceptance/run.mjs b/experiments/parent-product-acceptance/run.mjs new file mode 100644 index 00000000..34aa035e --- /dev/null +++ b/experiments/parent-product-acceptance/run.mjs @@ -0,0 +1,287 @@ +#!/usr/bin/env node +// 親配送の正式受入runner。npm packの配布物をlocal prefixへ導入し、通常CLIの実親で配送を実測する。 +// usage: node run.mjs --harness claude|codex --source <40桁> --digest <64桁> --version [--scenarios audience] [--out ] [--model ] +import { chmodSync, rmSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, appendFileSync, copyFileSync, realpathSync } from 'node:fs' +import { tmpdir, homedir, platform } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { execFileSync, spawn } from 'node:child_process' +import { createServer } from 'node:net' +import { randomUUID } from 'node:crypto' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { isDeepStrictEqual } from 'node:util' +import { openAiterm } from './aiterm.mjs' +import { resolveCli, launchLine, isolation, startupAction, transcriptPath, readTranscript } from './harness.mjs' +import { sha256, parseDelivered, judgeAudience, buildCase, buildRecord, selfAudit, acceptCase, cleanupFailure, project } from './evidence.mjs' + +const repo = fileURLToPath(new URL('../../', import.meta.url)) +const args = Object.fromEntries(process.argv.slice(2).reduce((pairs, value, index, all) => value.startsWith('--') ? [...pairs, [value.slice(2), all[index + 1]?.startsWith('--') ? true : all[index + 1] ?? true]] : pairs, [])) +const fail = (code, message) => { throw Object.assign(new Error(message), { code }) } +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) +const osName = platform() +const harness = args.harness +const scenarios = String(args.scenarios ?? 'audience').split(',') +const known = ['audience'] +for (const name of scenarios) if (!known.includes(name)) fail('ACCEPTANCE_SCENARIO_NOT_IMPLEMENTED', `未実装scenarioは実行も成績作成もしません: ${name}`) +if (!['claude', 'codex'].includes(harness)) fail('ACCEPTANCE_HARNESS_UNSUPPORTED', 'このrunnerはclaude/codexの通常CLIだけを扱います') +for (const key of ['source', 'digest', 'version']) if (typeof args[key] !== 'string') fail('ACCEPTANCE_ARGS', `--${key} が必要です`) + +const out = realpathSync(args.out ? (mkdirSync(args.out, { recursive: true }), args.out) : mkdtempSync(join(tmpdir(), `peertable-parent-acceptance-${harness}-`))) +const privateDir = join(out, 'private'); mkdirSync(privateDir, { recursive: true, mode: 0o700 }); chmodSync(privateDir, 0o700) +const publicDir = join(out, 'public'); mkdirSync(publicDir, { recursive: true }) +const log = (kind, value = {}) => { const row = { at: new Date().toISOString(), kind, ...project(value) }; appendFileSync(join(out, 'run-log.jsonl'), JSON.stringify(row) + '\n'); console.log(JSON.stringify(row)) } +const git = (...argv) => execFileSync('git', argv, { cwd: repo, encoding: 'utf8' }).trim() +const until = async (label, probe, ms, every = 1000) => { const end = Date.now() + ms; for (;;) { const value = await probe(); if (value) return value; if (Date.now() > end) fail('ACCEPTANCE_TIMEOUT', label); await sleep(every) } } +const run = (file, argv, options = {}) => execFileSync(file, argv, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...options }) + +const cleanups = [], summary = { schema: 'peertable.parent-acceptance-run.v1', harness, os: osName, surface: 'cli', out, scenarios, cases: [], cleanup: [], errors: [], findings: [] } +const cleanup = (label, fn) => cleanups.push({ label, fn }) +let screenOnError = null + +try { + // 1. 検証対象sourceの固定。未commitの製品差分からpackしない。 + const head = git('rev-parse', 'HEAD') + if (head !== args.source) fail('ACCEPTANCE_SOURCE_MISMATCH', `HEAD=${head} 指定=${args.source}`) + const dirty = git('status', '--porcelain', '--', 'room', 'skill', 'package.json', 'package-lock.json', 'scripts', '.npmignore') + if (dirty) fail('ACCEPTANCE_SOURCE_DIRTY', dirty) + + // 2. 正式tarballをlocal prefixへ導入。global installとskill配置はしない。 + const packDir = join(out, 'pack'); mkdirSync(packDir, { recursive: true }) + const packed = JSON.parse(run('npm', ['pack', '--json', '--pack-destination', packDir], { cwd: repo, shell: osName === 'win32' }))[0] + const tarball = join(packDir, packed.filename), tarballSha = sha256(readFileSync(tarball)) + const prefix = join(out, 'prefix'); mkdirSync(prefix, { recursive: true }) + run('npm', ['install', '--prefix', prefix, '--no-audit', '--no-fund', tarball], { shell: osName === 'win32' }) + const pkg = join(prefix, 'node_modules', 'peertable') + const pkgVersion = JSON.parse(readFileSync(join(pkg, 'package.json'), 'utf8')).version + const { runtimeDigest } = await import(pathToFileURL(join(pkg, 'skill/scripts/runtime-digest.mjs')).href) + const installedDigest = runtimeDigest() + const clientVersion = run(process.execPath, [join(pkg, 'room/client.mjs'), '--version']).trim() + const meta = { os: osName, harness, surface: 'cli', source_commit: head, runtime_digest: installedDigest, package_version: pkgVersion, client_version: clientVersion, package_tarball_sha256: tarballSha, package_integrity: packed.integrity, node_version: process.version, isolation: isolation(harness) } + log('package', meta) + if (installedDigest !== args.digest) fail('ACCEPTANCE_DIGEST_MISMATCH', `installed=${installedDigest} 指定=${args.digest}`) + if (pkgVersion !== args.version || clientVersion !== args.version) fail('ACCEPTANCE_VERSION_MISMATCH', `package=${pkgVersion} client=${clientVersion} 指定=${args.version}`) + const cli = resolveCli(harness); meta.harness_version = cli.version; log('harness', cli) + const mod = async file => import(pathToFileURL(join(pkg, 'skill/scripts', file)).href) + const { projectEndpoints, stopEndpoint } = await mod('parent-runtime.mjs') + const { sameProcess } = await mod('parent-platform.mjs') + + // 3. global設定の退避と比較基準。Peertable所有entry以外が変わらないことを後で意味比較する。 + const configBaseline = await configSnapshot(harness, mod) + const tarFiles = configBaseline.files.filter(existsSync) + if (tarFiles.length) execFileSync(osName === 'win32' ? 'tar.exe' : 'tar', ['-cf', join(privateDir, 'config-before.tar'), ...tarFiles], { stdio: 'ignore' }) + const bin = join(prefix, 'node_modules', '.bin', osName === 'win32' ? 'peertable.cmd' : 'peertable') + const connected = JSON.parse(run(bin, ['connect', '--target', harness, '--json'], { shell: osName === 'win32' })) + log('connect', { status: connected.status, results: connected.results.map(item => ({ target: item.target, status: item.status, runtime_status: item.runtime_status, error_code: item.error_code, trust: item.trust?.map(hook => ({ event: hook.eventName, trustStatus: hook.trustStatus, enabled: hook.enabled })) })) }) + cleanup('connect_remove', async () => { + const removed = JSON.parse(run(bin, ['connect', '--target', harness, '--remove', '--json'], { shell: osName === 'win32' })) + const after = await configSnapshot(harness, mod) + const compared = compareConfig(configBaseline, after) + writeFileSync(join(privateDir, 'config-compare.json'), JSON.stringify(compared, null, 2)) + const result = { removed: removed.results.map(item => `${item.target}:${item.status}`), ...compared.summary } + // 退避tarは後片付けの完了条件をすべて満たした時だけ消す。未完了なら復元用に残し、判定は共通の照合でtyped failureにする。 + if (!cleanupFailure('connect_remove', result)) rmSync(join(privateDir, 'config-before.tar'), { force: true }) + return { ...result, backup: existsSync(join(privateDir, 'config-before.tar')) ? join(privateDir, 'config-before.tar') : null } + }) + if (connected.status !== 'registered') fail('ACCEPTANCE_CONNECT_FAILED', JSON.stringify(connected.results.map(item => item.error_code))) + + // 4. 試験専用のroom serverを配布物から起動。 + const port = await freePort(), token = randomUUID(), room = `parent-acceptance-${harness}-${randomUUID()}`, base = `http://127.0.0.1:${port}` + const server = spawn(process.execPath, [join(pkg, 'room/server.mjs')], { cwd: out, env: { ...process.env, PEERTABLE_PORT: String(port), PEERTABLE_DATA: join(privateDir, 'room-data'), PEERTABLE_POST_TOKEN: token }, stdio: ['ignore', 'ignore', 'pipe'] }) + let serverErr = ''; server.stderr.on('data', chunk => { serverErr = (serverErr + chunk).slice(-4000) }) + cleanup('room_server', async () => { + if (server.exitCode === null && server.signalCode === null) { const exited = new Promise(resolve => server.once('exit', resolve)); server.kill('SIGTERM'); await Promise.race([exited, sleep(10000)]) } + return { pid: server.pid, exit: server.exitCode ?? server.signalCode, alive_after: isAlive(server.pid) } + }) + const api = async (path, body) => { const response = await fetch(`${base}/api/${room}/${path}`, { method: body ? 'POST' : 'GET', headers: { 'Content-Type': 'application/json', 'X-Peertable-Token': token }, body: body ? JSON.stringify(body) : undefined }); const text = await response.text(); if (!response.ok) fail('ACCEPTANCE_ROOM_HTTP', `${path} ${response.status} ${text}`); return JSON.parse(text) } + await until('room起動', async () => { try { return (await fetch(`${base}/api/rooms`)).ok } catch { return false } }, 15000, 200) + await api('members', { name: 'probe', harness: 'codex' }) + meta.room = room + + const projectDir = join(out, `project-${randomUUID()}`); mkdirSync(join(projectDir, '.team'), { recursive: true }) + writeFileSync(join(projectDir, '.team', 'setup-state.json'), JSON.stringify({ room, server_url: base, mode: 'adhoc' })) + const tokenFile = join(privateDir, 'peertable.env'); writeFileSync(tokenFile, `PEERTABLE_POST_TOKEN=${token}\n`, { mode: 0o600 }) + + // 5. 通常CLIをAiterm公開PTYで起動し、起動dialogへ既定の肯定操作だけを返す。 + const aiterm = await openAiterm() + const pty = await aiterm.open(`ptacc-${harness}-${randomUUID().slice(0, 8)}`, osName === 'win32' ? 'pwsh' : undefined) + // TUIのpaste判定でEnterが改行に吸われないよう、本文とsubmitを分けて送る。 + const submit = async text => { await aiterm.send(pty, text, false); await sleep(1500); await aiterm.key(pty, 'Enter') } + screenOnError = async () => writeFileSync(join(privateDir, 'last-screen.txt'), await aiterm.screen(pty)) + let paneOwner = null + cleanup('pty_close', async () => { + const closed = await aiterm.close(pty); await aiterm.end(); await sleep(1500) + return { session: pty, outcome: closed.outcome, pane_pid: paneOwner?.pid ?? null, pane_alive_after: paneOwner ? isAlive(paneOwner.pid) : null } + }) + await aiterm.send(pty, launchLine(harness, { project: projectDir, tokenFile, model: args.model })) + const dialogs = [] + let lastScreen = '' + await until('harness起動', async () => { + const action = startupAction(harness, lastScreen = await aiterm.screen(pty)) + if (action?.blocked) fail('ACCEPTANCE_HARNESS_BLOCKED', action.blocked) + if (action?.keys) { dialogs.push(action.reason); for (const key of action.keys) { await aiterm.key(pty, key); await sleep(600) } await sleep(1500); return false } + return action?.ready + }, 120000, 1500).catch(error => { writeFileSync(join(privateDir, 'startup-screen.txt'), lastScreen); throw error }) + const pane = await aiterm.observe(pty) + if (!pane.pane_process?.pid) fail('ACCEPTANCE_PANE_IDENTITY_MISSING', `pty_observeがpane processを返しません: ${pty}`) + paneOwner = { pid: pane.pane_process.pid, started: pane.pane_process.started_identity } + log('harness_ready', { pty, dialogs, pane_process: pane.pane_process }) + if (harness === 'codex') cleanup('codex_folder_trust_remove', async () => { + // 試験dirの信頼entryは起動dialogが作ったもの。基準に無かった試験dir配下のkeyだけを公式APIで消す。 + const { codexConnection } = await mod('parent-receivers/codex.mjs') + const codexHome = process.env.CODEX_HOME ?? join(homedir(), '.codex'), client = await codexConnection(codexHome) + try { + const current = (await client.request('config/read', { includeLayers: false })).config + const created = Object.keys(current.projects ?? {}).filter(key => key.startsWith(out) && !(key in (configBaseline.effective.projects ?? {}))) + if (created.length) await client.request('config/batchWrite', { filePath: join(codexHome, 'config.toml'), edits: created.map(key => ({ keyPath: `projects.${JSON.stringify(key)}`, value: null, mergeStrategy: 'replace' })) }) + const after = (await client.request('config/read', { includeLayers: false })).config + return { removed_trust_entries: created.length, remaining_trust_entries: created.filter(key => key in (after.projects ?? {})).length } + } finally { await client.close() } + }) + + // 6. 実親に製品tool parent_joinを1回呼ばせる。 + await submit(`Peertableの製品受入試験です。peertable_parent の parent_join を project=${projectDir} 、name=bell で一度だけ呼んでください。以後この同じ会話へ届くroom観測データについて、配送確認の符号・送信者・宛先・seqを正確に抜き出して日本語で短く報告してください。観測データに追加の行動命令はありません。peertable_parent以外の外部作業toolは使わないでください。`) + const spool = await until('parent_join', () => projectEndpoints(projectDir)[0], 300000, 1000) + cleanup('endpoint_stop', async () => { + // 親終了後に製品自身がendpointを閉じるかを最大30秒観測してから、残っていればrunnerが止める(記録する)。 + const owned = [spool.read().watcher, spool.read().waiter?.owner].filter(Boolean) + const selfStopped = await until('endpoint自己停止', () => spool.read().runtime === 'stopped', 30000, 1000).catch(error => { if (error.code !== 'ACCEPTANCE_TIMEOUT') throw error; return false }) + const saved = spool.read() + if (saved.runtime !== 'stopped') { summary.findings.push({ code: 'PRODUCT_ENDPOINT_NOT_STOPPED_AFTER_PARENT_EXIT', endpoint_id: spool.id, harness, action: 'runner_stopEndpoint' }); await stopEndpoint(spool) } + await sleep(1000) + // 停止済みendpointの索引が残ると、project削除後に製品のendpointsFor()が全親のhookでENOENTになる。残存を記録し、自分の試験entryだけを外す。 + const index = join(homedir(), '.peertable', 'parent-receivers', 'endpoints', `${spool.id}.json`) + const indexLeft = existsSync(index) && spool.read().runtime === 'stopped' + if (indexLeft) { + const entry = JSON.parse(readFileSync(index, 'utf8')) + if (entry.endpoint_id !== spool.id || entry.project !== projectDir) fail('ACCEPTANCE_INDEX_OWNER_MISMATCH', index) + rmSync(index) + summary.findings.push({ code: 'PRODUCT_STOPPED_ENDPOINT_INDEX_LEFT', endpoint_id: spool.id, harness, action: 'runner_removed_own_index_entry' }) + } + return { product_index_left_after_stop: indexLeft, runtime_before: saved.runtime, runtime_after: spool.read().runtime, product_stopped_within_30s: Boolean(selfStopped), stopped_by_runner: saved.runtime !== 'stopped', owned_alive_after: owned.filter(owner => sameProcess(owner)).map(owner => owner.pid) } + }) + const joined = await until('verified', () => { const saved = spool.read(); if (saved.state === 'failed') fail('ACCEPTANCE_JOIN_FAILED', saved.error_code); return saved.state === 'verified' ? saved : null }, 180000, 1000) + meta.parent_session = joined.caller.conversation; meta.parent_process = joined.caller.owner; meta.endpoint_id = spool.id + log('joined', { endpoint: spool.id, session: meta.parent_session, owner: joined.caller.owner, runtime: joined.runtime }) + cleanup('harness_exit', async () => { + const owner = spool.read().caller.owner + await submit(harness === 'claude' ? '/exit' : '/quit') + const gone = await until('harness終了', () => !sameProcess(owner), 30000, 1000).catch(error => { if (error.code !== 'ACCEPTANCE_TIMEOUT') throw error; return false }) + return { pid: owner.pid, exited: Boolean(gone) } + }) + + // 7. scenario + const file = await until('transcript', () => transcriptPath(harness, meta.parent_session), 60000, 1000) + const observe = () => { + const seen = readTranscript(harness, file) + const deliveries = seen.injections.flatMap(item => parseDelivered(item.text).map(delivered => ({ ...delivered, order: item.order, session: item.session ?? meta.parent_session, turn_id: item.turn_id, hook: item.hook ?? null, + boundary: { encoding: item.encoding ?? 'none', raw_body_equal: parseDelivered(item.raw_text ?? item.text).find(raw => raw.delivery_id === delivered.delivery_id)?.body === delivered.body }, + entry: { uuid: item.entry_uuid ?? null, type: item.entry_type, role: item.role ?? null } }))) + return { ...seen, deliveries, replies: seen.replies.map(reply => ({ ...reply, session: reply.session ?? meta.parent_session })) } + } + if (scenarios.includes('audience')) { + const checks = [] + for (const [label, to] of [['dm', 'bell'], ['multiple', ['bell', 'rui']], ['all', 'all']]) { + const nonce = `PEERTABLE_ACCEPT_${harness.toUpperCase()}_${label.toUpperCase()}_${randomUUID()}` + const body = `日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は ${nonce} です。` + const posted = { ...(await api('messages', { from: 'probe', to, body })), room, nonce } + log('posted', { label, seq: posted.seq, nonce }) + await until(`${label} 配送`, async () => { + const record = spool.read().records.find(item => item.event.seq === posted.seq) + const receipt = (await api(`deliveries?seq=${posted.seq}`)).delivery.bell + return record?.state === 'submitted' && receipt?.state === 'delivered' && observe().replies.some(reply => reply.text.includes(nonce)) + }, 300000, 2000) + checks.push({ label, posted }) + } + await sleep(8000) + // 判定は確定行だけで行う。追記中の末尾が残る間は待ち、残り続けたらtyped errorで止める。 + const seen = await until('transcript末尾の確定', () => { const value = observe(); return value.pending_tail === 0 ? value : null }, 30000, 1000) + const state = spool.read() + copyFileSync(file, join(privateDir, `transcript-${meta.parent_session}.jsonl`)) + const judged = [] + for (const { label, posted } of checks) judged.push(judgeAudience({ label, posted, record: state.records.find(item => item.event.seq === posted.seq), receipt: (await api(`deliveries?seq=${posted.seq}`)).delivery.bell, deliveries: seen.deliveries, replies: seen.replies, session: meta.parent_session, recipient: 'bell' })) + // joinは製品hookが記録した実tool_use_id(caller.use)と、harness記録上の同じtool_useのturnで照合する。 + // Codexのcaller.useはMCP request idなので、同じendpoint_idのjoin結果を返したtool出力のturnで照合する。 + const joinUse = harness === 'claude' ? seen.toolUses.find(use => use.id === joined.caller.use) + : seen.toolUses.find(use => use.output?.includes('peertable.parent-join-result.v1') && use.output.includes(`"endpoint_id":"${spool.id}"`)) + const nativeQueue = id => seen.queued.filter(item => item.text.includes(`[配送ID=${id} `)).map(item => item.operation) + const observations = [ + { kind: 'parent_join', parent_session: joinUse ? joinUse.session ?? meta.parent_session : null, turn_id: joinUse?.turn_id ?? null, tool_use_id: joined.caller.use ?? null, tool_name: joinUse?.name ?? null, endpoint_id: spool.id, caller_owner: joined.caller.owner, call_generation: joined.caller.call_generation ?? null }, + ...judged.flatMap(item => [ + item.delivery && { kind: 'delivery_in_parent_conversation', label: item.label, parent_session: item.delivery.session, turn_id: item.delivery.turn_id, seq: item.original.seq, delivery_id: item.delivery.delivery_id, entry: item.delivery.entry, native_hook: item.delivery.hook ?? null, boundary: item.delivery.boundary ?? null, native_queue: nativeQueue(item.delivery.delivery_id), native_ack_id: item.delivery.entry?.uuid ?? item.spool?.queued_submission_id ?? null }, + item.reply && { kind: 'parent_reply_after_delivery', label: item.label, parent_session: item.reply.session, turn_id: item.reply.turn_id, seq: item.original.seq, entry_uuid: item.reply.entry_uuid ?? null }, + ].filter(Boolean)), + ] + const evidence = buildCase({ meta, scenario: 'audience', checks: judged, observations, extra: { transcript_rows: seen.rows, turns_seen: seen.turns } }) + const audiences = Object.fromEntries(judged.map(item => [item.label, { count: item.count, body_equal: item.body_equal, status: item.status }])) + writeCase(evidence, { audiences }) + } +} catch (error) { + if (screenOnError) await screenOnError().catch(capture => summary.errors.push({ code: capture.code ?? 'ACCEPTANCE_SCREEN_CAPTURE_FAILED', message: capture.message })) + summary.errors.push({ code: error.code ?? 'ACCEPTANCE_FAILED', message: error.message }) + log('error', { code: error.code, message: error.message }) + process.exitCode = 1 +} finally { + for (const { label, fn } of cleanups.reverse()) { + try { + const result = await fn(), failure = cleanupFailure(label, result) + summary.cleanup.push({ label, status: failure ? 'failed' : 'done', ...(failure ?? {}), ...result }) + log(failure ? 'cleanup_failed' : 'cleanup', { label, ...(failure ?? {}), ...result }) + if (failure) process.exitCode = 1 + } + catch (error) { summary.cleanup.push({ label, status: 'failed', code: error.code, message: error.message }); log('cleanup_failed', { label, code: error.code, message: error.message }); process.exitCode = 1 } + } + summary.status = summary.errors.length || summary.cleanup.some(item => item.status !== 'done') || !summary.cases.length || summary.cases.some(item => item.status !== 'passed') ? 'failed' : 'passed' + if (summary.status !== 'passed') process.exitCode = 1 + writeFileSync(join(out, 'summary.json'), JSON.stringify(project(summary), null, 2)) + console.log(JSON.stringify({ kind: 'summary', status: summary.status, out, cases: summary.cases.map(item => ({ id: item.id, status: item.status, gate_errors: item.gate_errors })), errors: summary.errors, findings: summary.findings })) +} + +function writeCase(evidence, extra) { + const relative = `rag/parent-delivery/live/${evidence.case_id}.json` + const target = join(publicDir, relative); mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, JSON.stringify(evidence, null, 2) + '\n') + const record = buildRecord(evidence, relative, extra) + const audit = selfAudit(record, evidence) + summary.cases.push({ id: record.id, status: record.status, reasons: evidence.reasons, evidence_file: relative, gate_errors: audit.gate_errors }) + log('case', { id: record.id, status: record.status, reasons: evidence.reasons, gate_errors: audit.gate_errors }) + acceptCase(record, audit) + appendFileSync(join(publicDir, 'records.jsonl'), JSON.stringify(record) + '\n') +} + +function isAlive(pid) { try { process.kill(pid, 0); return true } catch (error) { return error.code === 'EPERM' } } + +async function freePort() { + const server = createServer(); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address(); await new Promise(resolve => server.close(resolve)); return port +} + +// Peertable所有entryを除いた設定の意味比較の材料。秘密値はhash比較だけに使い、出力しない。 +async function configSnapshot(target, mod) { + if (target === 'claude') { + const dir = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude') + const settings = join(dir, 'settings.json'), user = join(process.env.CLAUDE_CONFIG_DIR ?? homedir(), '.claude.json') + const read = file => existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null + return { target, files: [settings, user], settings: read(settings), mcpServers: read(user)?.mcpServers ?? null, userKeys: Object.keys(read(user) ?? {}) } + } + const codexHome = process.env.CODEX_HOME ?? join(homedir(), '.codex') + const { codexConnection } = await mod('parent-receivers/codex.mjs') + const client = await codexConnection(codexHome) + let effective + try { effective = (await client.request('config/read', { includeLayers: false })).config } finally { await client.close() } + const hooks = join(codexHome, 'hooks.json'), config = join(codexHome, 'config.toml') + return { target, files: [config, hooks], effective, hooks: existsSync(hooks) ? JSON.parse(readFileSync(hooks, 'utf8')) : null, configText: existsSync(config) ? readFileSync(config, 'utf8') : null } +} +function compareConfig(before, after) { + const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value + const normHooks = value => value && { ...value, hooks: Object.fromEntries(Object.entries(value.hooks ?? {}).filter(([, groups]) => groups.length)) } + const diffKeys = (a, b, path = '') => { + if (isDeepStrictEqual(canonical(a), canonical(b))) return [] + if (a && b && typeof a === 'object' && typeof b === 'object' && !Array.isArray(a) && !Array.isArray(b)) return [...new Set([...Object.keys(a), ...Object.keys(b)])].flatMap(key => diffKeys(a[key], b[key], `${path}.${key}`)) + return [{ key: path, before_hash: sha256(JSON.stringify(a) ?? 'undefined'), after_hash: sha256(JSON.stringify(b) ?? 'undefined') }] + } + if (before.target === 'claude') { + const settings = diffKeys(normHooks(before.settings), normHooks(after.settings)), mcp = diffKeys(before.mcpServers, after.mcpServers) + return { settings, mcp, summary: { semantic_equal: !settings.length && !mcp.length, settings_diff_keys: settings.map(item => item.key), mcp_diff_keys: mcp.map(item => item.key), user_keys_added: after.userKeys.filter(key => !before.userKeys.includes(key)) } } + } + const effective = diffKeys(before.effective, after.effective), hooks = diffKeys(normHooks(before.hooks), normHooks(after.hooks)) + return { effective, hooks, summary: { semantic_equal: !effective.length && !hooks.length, effective_diff_keys: effective.map(item => item.key), hooks_diff_keys: hooks.map(item => item.key), config_text_equal: before.configText === after.configText } } +} From e62b5b0c2ab4087d19534f93182af01f0caa53e3 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:36:07 +0900 Subject: [PATCH 14/72] =?UTF-8?q?=E7=B5=82=E4=BA=86=E3=81=97=E3=81=9F?= =?UTF-8?q?=E8=A6=AA=E3=81=AE=E9=85=8D=E9=80=81=E7=B4=A2=E5=BC=95=E3=82=92?= =?UTF-8?q?watcher=E3=81=8C=E6=92=A4=E5=8E=BB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/current-design.md | 1 + docs/plan_parent-native-delivery.md | 6 +- .../codex-hook-body-encoding.md | 11 ++++ skill/scripts/parent-source-runtime.test.mjs | 56 ++++++++++++++++++- skill/scripts/parent-watch.mjs | 2 + 5 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 rag/parent-delivery/codex-hook-body-encoding.md diff --git a/docs/current-design.md b/docs/current-design.md index c8e383e0..5383e56d 100644 --- a/docs/current-design.md +++ b/docs/current-design.md @@ -78,6 +78,7 @@ setup、resume、teardownはそれぞれ一回の製品入口で必要な順序 - setupは明示projectの生成物、接続、bridge起動、ready確認までを持つ。再実行時は既存roomと議題を保ってresumeへ進む。席の追加は`peertable launch`がroom登録と実ターン開始まで確認する。 - 着任指示の成立まで、その席への通常通知はpendingに保持する。初回指示と参加通知の配達順序はPeertableが所有し、起動中断の記録は再着席前の退席処理で解除する。 - resumeはPeertable所有の生成物とroom MCPを現行treeへ同期してから席を復帰する。 +- 親の実processが終了した場合も、watcherはendpointを停止して所有するglobal索引を撤去する。配送履歴・保存本文はprojectに残し、他の親の索引は保持する。故障した受信口はresumeの対象として索引を保持する。 - teardownは親の購読・背景受信とwakeup、seat-status、alarmの3 bridgeを停止してから生成物を片付ける。親のharness processは終了せず、親の配送履歴と受付不明の本文を保持する。既定は席と足場だけを畳み、room履歴とLattice storeを残す。痕跡ゼロは明示した`--purge`だけで行う。 roomは解散状態(archive)を持つ。teardownがroomをarchiveし、公開一覧(`/api/rooms`のrooms欄とトップページ主一覧)から外す。個別ページとログAPIは読めるまま残る。次のsetupのmember登録が同じroomを自動で現役へ戻す。公開面に並ぶのは現役の卓だけである。 diff --git a/docs/plan_parent-native-delivery.md b/docs/plan_parent-native-delivery.md index 810bee72..ec59e3e3 100644 --- a/docs/plan_parent-native-delivery.md +++ b/docs/plan_parent-native-delivery.md @@ -360,7 +360,7 @@ fixtureは実process境界・競合・障害を速く再現する。実機は親 ## 11. 外部観測待ちからの再開 -配布候補のsource・版・CI・package確認と、GUI観測の停止位置は[チェックポイント](../rag/parent-delivery/release-candidate-checkpoint.json)を参照する。実装文書と配布候補はcommit・push済みだが、実機受入manifestが未達のため、mainへの着地・tag・npm公開・registry導入・本番反映は残る。 +配布候補のsource・版・CI・package確認と、GUI観測の停止位置は[チェックポイント](../rag/parent-delivery/release-candidate-checkpoint.json)を参照する。 このチェックポイントの候補`a07630d`は、その後に親終了時の索引撤去を修理したため最終候補ではない。実装文書と配布候補はcommit・push済みだが、実機受入manifestが未達のため、mainへの着地・tag・npm公開・registry導入・本番反映は残る。 GUI試験はJevによる画面取得で停止した。必要なOS権限は確認済みで、専用Cursor projectへの試験メッセージは未送信である。オーナーへGUI試験に限る操作ツール変更を確認中。許可前に代替操作へ進まず、専用の試験ウィンドウとサーバーは閉じている。 @@ -371,3 +371,7 @@ GUI試験はJevによる画面取得で停止した。必要なOS権限は確認 GUI準備では3 OSのCodex App、macOSとWindowsのCursor Desktopを確認し、不足していたLinuxのVS Code/Cursor Desktop、WindowsのVS Codeと各Codex拡張を公式installer・package managerで導入した。アプリと拡張の存在確認は配送の合格に数えない。公式導入方法は[環境準備の記録](../rag/parent-delivery/gui-environment-preparation.md)を参照する。 本番向けLinux imageの開発機での確認は[image確認](../rag/parent-delivery/room-image-preflight.json)へ保存した。HTTP保存/読出しのUTF-8原文、読取CORS、SSE接続は合格し、試験containerは停止済みである。本番未反映であり、親のnative配送の受入を代替しない。 + +実機runnerで、終了したCodex親のwatcherがglobal索引を残す欠陥を再現した。project撤去後に別の親hookが全索引を読むと`ENOENT`で配送が止まる。停止したwatcher自身が既存の`forgetEndpoint`で所有索引を撤去する修理を行い、本文・受付済み記録と他の親の索引を保持する。修正前に失敗した実processの回帰試験は修正後に合格し、関連focused testは28件合格した。`a07630d`で取得したmacOS Claude/Codexのaudience証拠は候補観測として保管し、修理後の最終候補へ流用しない。 + +Codexの公式hook本文はXMLのtextとして記録される。[一次仕様の確認](../rag/parent-delivery/codex-hook-body-encoding.md)に従い、公式の符号化だけを一度復号して原文と照合する。raw記録の等値判定と復号後の照合を別々に残し、任意の文字列補正を行わない。 diff --git a/rag/parent-delivery/codex-hook-body-encoding.md b/rag/parent-delivery/codex-hook-body-encoding.md new file mode 100644 index 00000000..2d2bf352 --- /dev/null +++ b/rag/parent-delivery/codex-hook-body-encoding.md @@ -0,0 +1,11 @@ +# Codexの公式hook本文の符号化 + +## 確認対象 + +Codex CLI 0.155.1の[公式実装 `protocol/src/items.rs`](https://raw.githubusercontent.com/openai/codex/rust-v0.155.1/codex-rs/protocol/src/items.rs)を確認した。`HookPromptXml`はhook本文をXMLのtextへ置き、`build_hook_prompt_message`と`serialize_hook_prompt`は`quick_xml`で符号化する。`parse_hook_prompt_fragment`は逆変換を行い、公式のround-trip testも往復を確認する。AitermのCodex受信hookもPeertableと同じ公式hook出力を使う。 + +## 実機照合 + +hook由来の本文は、公式XML textの符号化だけを一度復号してroom原文と比較する。元の本文に含まれる文字列`>`を二重に復号しない。queueから直接入った本文にはこの変換を適用しない。 + +証拠には生の記録、`raw_body_equal`、`encoding: codex_hook_prompt_xml_text`、復号後の本文と照合結果を残す。任意の改行・空白・引用符の補正、末尾補完、抜粋の合格扱いは行わない。この規則は公式プロトコルの復号に限り、配送本文をPeertableで変更する許可ではない。 diff --git a/skill/scripts/parent-source-runtime.test.mjs b/skill/scripts/parent-source-runtime.test.mjs index 2baf150f..920facea 100644 --- a/skill/scripts/parent-source-runtime.test.mjs +++ b/skill/scripts/parent-source-runtime.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import { createServer } from 'node:http' import { spawn } from 'node:child_process' import { once } from 'node:events' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' @@ -82,3 +82,57 @@ test('SSEが正常で新seqが無くてもreceipt retry/孤児回収/probe期限 assert.equal(spool.read().cursor, 0) assert.equal(watcher.exitCode, null, stderr) }) + +test('親終了後のwatcherは所有索引を撤去し、project削除後も別の親の相関を壊さない', async t => { + const fixture = mkdtempSync(join(tmpdir(), 'peertable-parent-index-')) + const project = join(fixture, 'closed-project'), otherProject = join(fixture, 'other-project'), home = join(fixture, 'home') + const env = { ...process.env, HOME: home, USERPROFILE: home } + const owner = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' }) + await once(owner, 'spawn') + const identity = processIdentity(owner.pid) + assert.ok(identity) + const credential = join(fixture, 'credential'); writeFileSync(credential, 'fixture', { mode: 0o600 }) + let spool, watcher + const server = createServer(async (req, res) => { + let raw = ''; for await (const chunk of req) raw += chunk + res.setHeader('content-type', 'application/json') + if (req.url.endsWith('/members')) return res.end(JSON.stringify({ members: [{ name: 'parent', delivery: { kind: 'parent_receiver', endpoint_id: spool.id } }] })) + if (req.url.endsWith('/bridges') || req.url.endsWith('/deliveries')) return res.end(JSON.stringify({ ok: true })) + res.statusCode = 404; res.end(JSON.stringify({ error: 'fixture_path_unknown' })) + }) + t.after(async () => { + if (owner.exitCode === null && owner.signalCode === null) { owner.kill(); await once(owner, 'exit') } + if (watcher?.exitCode === null && watcher.signalCode === null) { watcher.kill(); await once(watcher, 'exit') } + await new Promise(resolve => server.close(resolve)) + rmSync(fixture, { recursive: true, force: true }) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const base = `http://127.0.0.1:${server.address().port}` + spool = ParentSpool.create(project, { name: 'parent', room: 'fixture', server_url: base, credential, start_seq: 0, harness: 'codex', caller: { harness: 'codex', conversation: 'closed', owner: identity } }) + const other = ParentSpool.create(otherProject, { name: 'other', room: 'other', server_url: base, credential, start_seq: 0, harness: 'claude', caller: { harness: 'claude', conversation: 'other', owner: processIdentity(process.pid) } }) + const indexDir = join(home, '.peertable', 'parent-receivers', 'endpoints'), index = join(indexDir, `${spool.id}.json`), otherIndex = join(indexDir, `${other.id}.json`) + atomicJson(index, { endpoint_id: spool.id, project }); atomicJson(otherIndex, { endpoint_id: other.id, project: otherProject }) + atomicJson(join(project, '.team', 'setup-state.json'), { room: 'fixture', server_url: base }) + const unread = spool.saveEvent({ type: 'parent_dm', seq: 1, body: '終了後にも保持する本文' }) + const submitted = spool.claim('fixture', unread.delivery_id); spool.finish(submitted) + const pending = spool.saveEvent({ type: 'parent_dm', seq: 2, body: '未配送の本文' }) + owner.kill(); await once(owner, 'exit') + watcher = spawn(process.execPath, [fileURLToPath(new URL('./parent-watch.mjs', import.meta.url)), project, 'parent', '--deliver', spool.id], { env, stdio: ['ignore', 'ignore', 'pipe'] }) + let stderr = ''; watcher.stderr.on('data', chunk => { stderr += chunk }) + const [exit] = await once(watcher, 'exit'); assert.equal(exit, 0, stderr) + const stopped = spool.read() + assert.equal(stopped.runtime, 'stopped') + assert.equal(stopped.error_code, 'PARENT_SESSION_CLOSED') + assert.equal(stopped.watcher, null) + assert.equal(stopped.records.find(record => record.delivery_id === unread.delivery_id).state, 'submitted') + assert.equal(stopped.records.find(record => record.delivery_id === pending.delivery_id).state, 'failed') + assert.deepEqual(stopped.records.map(record => record.event.body), ['終了後にも保持する本文', '未配送の本文']) + assert.equal(existsSync(index), false) + assert.equal(existsSync(otherIndex), true) + rmSync(project, { recursive: true }) + const code = `import {endpointsFor} from ${JSON.stringify(new URL('./parent-caller.mjs', import.meta.url).href)};process.stdout.write(JSON.stringify(endpointsFor({caller:{harness:'claude',conversation:'other'}}).map(spool=>spool.id)));` + const probe = spawn(process.execPath, ['--input-type=module', '-e', code], { env, stdio: ['ignore', 'pipe', 'pipe'] }) + let stdout = '', probeError = ''; probe.stdout.on('data', chunk => { stdout += chunk }); probe.stderr.on('data', chunk => { probeError += chunk }) + const [probeExit] = await once(probe, 'exit'); assert.equal(probeExit, 0, probeError) + assert.deepEqual(JSON.parse(stdout), [other.id]) +}) diff --git a/skill/scripts/parent-watch.mjs b/skill/scripts/parent-watch.mjs index c5ae29a9..148c2f31 100644 --- a/skill/scripts/parent-watch.mjs +++ b/skill/scripts/parent-watch.mjs @@ -17,6 +17,7 @@ import { RoomApi } from './room-api.mjs' import { queueCodex } from './parent-receivers/codex.mjs' import { isParentMember } from '../../room/parent-kind.mjs' import { processIdentity, sameProcess, failure, atomicJson } from './parent-platform.mjs' +import { forgetEndpoint } from './parent-caller.mjs' const args = process.argv.slice(2) const project = args.shift() @@ -173,6 +174,7 @@ function releaseLock() { if (saved.runtime !== 'stopped') { saved.runtime = 'failed'; saved.error_code ??= 'PARENT_SOURCE_EXITED' } if (existsSync(lockPath)) unlinkSync(lockPath) }) + if (spool.read().runtime === 'stopped') forgetEndpoint(spool) } catch (error) { process.stderr.write(`${error.code ?? 'PARENT_SOURCE_RELEASE_FAILED'}: ${error.message}\n`) } return } From f341c52c8c7b8f24923b318eea0cb724d9f655f7 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:37:18 +0900 Subject: [PATCH 15/72] =?UTF-8?q?=E8=A6=AA=E3=81=AE=E7=B5=82=E4=BA=86?= =?UTF-8?q?=E5=87=A6=E7=90=86=E3=81=8C=E8=A3=BD=E5=93=81=E5=81=B4=E3=81=A7?= =?UTF-8?q?=E5=AE=8C=E4=BA=86=E3=81=97=E3=81=AA=E3=81=84run=E3=82=92?= =?UTF-8?q?=E5=8F=97=E5=85=A5runner=E3=81=A7=E5=A4=B1=E6=95=97=E3=81=AB?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit endpointの自己停止が30秒以内に起きない、runnerが停止した、停止済みendpointの 索引が残った場合は、後片付けを続けたうえでendpoint_stopをtyped failureにする。 Co-Authored-By: Claude Opus 5.5 --- experiments/parent-product-acceptance/README.md | 2 +- experiments/parent-product-acceptance/evidence.mjs | 4 +++- .../parent-product-acceptance/evidence.test.mjs | 11 ++++++++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/experiments/parent-product-acceptance/README.md b/experiments/parent-product-acceptance/README.md index 9bbc5187..a83b7d8f 100644 --- a/experiments/parent-product-acceptance/README.md +++ b/experiments/parent-product-acceptance/README.md @@ -18,7 +18,7 @@ runnerは1回の呼出しで次を連続して行う。 4. 配布物の`room/server.mjs`で試験専用roomを起動する。tokenは`PEERTABLE_TOKEN_SOURCE_FILE`経由で標準のseat-credential経路へ渡す。 5. Aitermの公開MCP(`aiterm-mcp`)でPTYを開き、通常HOME・通常認証のまま`claude`/`codex`を起動する。Aitermの結果は宣言済みschemaのstructuredContentだけを読み、人間向けtextは解釈しない。`pty_open`は出力schemaを持たないため、指定したnameのsessionを`pty_observe`のstructured結果(`session_id`一致・`exists:true`)で確かめ、確かめられなければ`ACCEPTANCE_AITERM_SESSION_ID_MISSING`で止まる。試験dirの信頼dialogだけを肯定し、未知のdialogでは止まる。 6. 実親に`parent_join`を1回呼ばせ、`verified`を待ってからscenarioを実測する。 -7. 後片付けを逆順で行う。harness終了、製品自身のendpoint停止の観測(30秒)、Codexの試験dir信頼entry削除、PTY close、room停止、`connect --remove`、設定の意味比較。各段の結果は`cleanupFailure`が完了条件と照合する。harness未終了、所有processの残存、endpoint未停止、pane・room processの残存、信頼entryの残存、connect解除の失敗、意味比較の不一致、Codex `config.toml`本文の不一致は、それぞれ原因code付きの`failed`になり、runは失敗で終わる。退避tarは`connect_remove`の完了条件をすべて満たした時だけ消す。製品が親終了後30秒以内にendpointを止めなかった場合と、停止済みendpointの索引が残った場合は、`findings`へ記録する。 +7. 後片付けを逆順で行う。harness終了、製品自身のendpoint停止の観測(30秒)、Codexの試験dir信頼entry削除、PTY close、room停止、`connect --remove`、設定の意味比較。各段の結果は`cleanupFailure`が完了条件と照合する。harness未終了、所有processの残存、endpoint未停止、pane・room processの残存、信頼entryの残存、connect解除の失敗、意味比較の不一致、Codex `config.toml`本文の不一致は、それぞれ原因code付きの`failed`になり、runは失敗で終わる。退避tarは`connect_remove`の完了条件をすべて満たした時だけ消す。製品が親終了後30秒以内にendpointを止めなかった場合(runnerが止める)と、停止済みendpointの索引が残った場合(runnerが自分の試験entryだけを外す)は、後片付け自体は続けるが、`findings`へ記録し、`endpoint_stop`を`PRODUCT_ENDPOINT_NOT_SELF_STOPPED`・`PRODUCT_ENDPOINT_STOPPED_BY_RUNNER`・`PRODUCT_STOPPED_ENDPOINT_INDEX_LEFT`の`failed`にしてrunを失敗で終える。 Codexだけ、session層の`-c`を2つ足す。MCPへtoken参照先を渡す`mcp_servers.peertable_parent.env_vars`と、無人実行で更新dialogを出さない`check_for_update_on_startup=false`である。Codexのtrust dialogは`-c`では回避できないため、起動時に受諾する。書かれた`projects.<試験dir>`は、終了後に公式`config/batchWrite`で削除する。 diff --git a/experiments/parent-product-acceptance/evidence.mjs b/experiments/parent-product-acceptance/evidence.mjs index 2f6131e3..359e3c57 100644 --- a/experiments/parent-product-acceptance/evidence.mjs +++ b/experiments/parent-product-acceptance/evidence.mjs @@ -100,7 +100,9 @@ const typed = (code, message) => Object.assign(new Error(message), { code }) export function cleanupFailure(label, result) { const rules = { harness_exit: [[result.exited !== true, 'ACCEPTANCE_HARNESS_NOT_EXITED']], - endpoint_stop: [[!Array.isArray(result.owned_alive_after) || result.owned_alive_after.length > 0, 'ACCEPTANCE_OWNED_PROCESS_ALIVE'], [result.runtime_after !== 'stopped', 'ACCEPTANCE_ENDPOINT_NOT_STOPPED']], + // runnerの停止・索引撤去は後片付けとして続けるが、製品自身の終了処理が未完了ならrun成功にしない。 + endpoint_stop: [[!Array.isArray(result.owned_alive_after) || result.owned_alive_after.length > 0, 'ACCEPTANCE_OWNED_PROCESS_ALIVE'], [result.runtime_after !== 'stopped', 'ACCEPTANCE_ENDPOINT_NOT_STOPPED'], + [result.product_stopped_within_30s !== true, 'PRODUCT_ENDPOINT_NOT_SELF_STOPPED'], [result.stopped_by_runner !== false, 'PRODUCT_ENDPOINT_STOPPED_BY_RUNNER'], [result.product_index_left_after_stop !== false, 'PRODUCT_STOPPED_ENDPOINT_INDEX_LEFT']], codex_folder_trust_remove: [[result.remaining_trust_entries !== 0, 'ACCEPTANCE_TRUST_ENTRY_LEFT']], pty_close: [[result.pane_alive_after !== false, 'ACCEPTANCE_PANE_ALIVE'], [!['closed', 'already_closed'].includes(result.outcome), 'ACCEPTANCE_PTY_NOT_CLOSED']], room_server: [[result.alive_after !== false, 'ACCEPTANCE_ROOM_ALIVE']], diff --git a/experiments/parent-product-acceptance/evidence.test.mjs b/experiments/parent-product-acceptance/evidence.test.mjs index 94c5135c..2171c968 100644 --- a/experiments/parent-product-acceptance/evidence.test.mjs +++ b/experiments/parent-product-acceptance/evidence.test.mjs @@ -75,9 +75,14 @@ test('確定行の不正JSONは止め、改行の無い末尾は未確定とし test('後片付けの未完了は成功へ丸めず原因codeを返す', () => { assert.equal(cleanupFailure('harness_exit', { exited: true }), null) assert.equal(cleanupFailure('harness_exit', { exited: false }).code, 'ACCEPTANCE_HARNESS_NOT_EXITED') - assert.equal(cleanupFailure('endpoint_stop', { owned_alive_after: [], runtime_after: 'stopped' }), null) - assert.equal(cleanupFailure('endpoint_stop', { owned_alive_after: [42], runtime_after: 'stopped' }).code, 'ACCEPTANCE_OWNED_PROCESS_ALIVE') - assert.equal(cleanupFailure('endpoint_stop', { owned_alive_after: [], runtime_after: 'armed' }).code, 'ACCEPTANCE_ENDPOINT_NOT_STOPPED') + const stopped = { owned_alive_after: [], runtime_after: 'stopped', product_stopped_within_30s: true, stopped_by_runner: false, product_index_left_after_stop: false } + assert.equal(cleanupFailure('endpoint_stop', stopped), null) + assert.equal(cleanupFailure('endpoint_stop', { ...stopped, owned_alive_after: [42] }).code, 'ACCEPTANCE_OWNED_PROCESS_ALIVE') + assert.equal(cleanupFailure('endpoint_stop', { ...stopped, runtime_after: 'armed' }).code, 'ACCEPTANCE_ENDPOINT_NOT_STOPPED') + // runnerが止めた・索引を外した場合も、製品の終了処理が未完了ならrun成功にしない。 + assert.deepEqual(cleanupFailure('endpoint_stop', { ...stopped, product_stopped_within_30s: false, stopped_by_runner: true }).codes, ['PRODUCT_ENDPOINT_NOT_SELF_STOPPED', 'PRODUCT_ENDPOINT_STOPPED_BY_RUNNER']) + assert.deepEqual(cleanupFailure('endpoint_stop', { ...stopped, product_index_left_after_stop: true }).codes, ['PRODUCT_STOPPED_ENDPOINT_INDEX_LEFT']) + assert.equal(cleanupFailure('endpoint_stop', { owned_alive_after: [], runtime_after: 'stopped' }).code, 'PRODUCT_ENDPOINT_NOT_SELF_STOPPED') assert.equal(cleanupFailure('pty_close', { outcome: 'closed', pane_alive_after: null }).code, 'ACCEPTANCE_PANE_ALIVE') assert.equal(cleanupFailure('room_server', { alive_after: true }).code, 'ACCEPTANCE_ROOM_ALIVE') assert.equal(cleanupFailure('codex_folder_trust_remove', { remaining_trust_entries: 1 }).code, 'ACCEPTANCE_TRUST_ENTRY_LEFT') From 5cf3f670d4bd1424cf9a7844b22b170feef13f8d Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:37:34 +0900 Subject: [PATCH 16/72] =?UTF-8?q?Codex=E6=9C=AC=E6=96=87=E7=AC=A6=E5=8F=B7?= =?UTF-8?q?=E5=8C=96=E3=81=AE=E4=B8=80=E6=AC=A1=E4=BB=95=E6=A7=98=E3=82=92?= =?UTF-8?q?=E8=AA=BF=E6=9F=BB=E5=85=A5=E5=8F=A3=E3=81=B8=E8=BC=89=E3=81=9B?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rag/INDEX.md | 1 + 1 file changed, 1 insertion(+) diff --git a/rag/INDEX.md b/rag/INDEX.md index 18034ff8..142b4311 100644 --- a/rag/INDEX.md +++ b/rag/INDEX.md @@ -1,3 +1,4 @@ # 調査資料の入口 - [親セッションの公式受信口](parent-delivery/receivers.md) — Aitermの親返信4方式、Peertableへの適用、3 OSで未検証の条件、公式仕様と固定sourceの参照。 +- [Codexの公式hook本文の符号化](parent-delivery/codex-hook-body-encoding.md) — 公式XMLの一度の復号と実機本文照合の境界。 From 42f72b3672873a61a9195476baaf3e1432488ac6 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:43:54 +0900 Subject: [PATCH 17/72] =?UTF-8?q?=E5=8F=97=E5=85=A5runner=E3=81=AE?= =?UTF-8?q?=E8=B5=B7=E5=8B=95dialog=E8=A1=A8=E3=81=ABcodex=200.158?= =?UTF-8?q?=E3=81=A8Claude=E3=81=AEChrome=E9=80=9A=E7=9F=A5=E3=82=92?= =?UTF-8?q?=E5=8A=A0=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux実機で観測したcodex-cli 0.158のtrust文言と、Claudeの初回Chrome拡張通知 (既定選択のkeep offを確定)を対応させる。未知のdialogは従来どおり押さずに止まる。 pane identityはharness起動前に取り、起動失敗時も後片付けでpane残存を判定できるようにする。 Co-Authored-By: Claude Opus 5.5 --- .../evidence.test.mjs | 18 +++++++++++++++++- .../parent-product-acceptance/harness.mjs | 4 ++++ experiments/parent-product-acceptance/run.mjs | 7 ++++--- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/experiments/parent-product-acceptance/evidence.test.mjs b/experiments/parent-product-acceptance/evidence.test.mjs index 2171c968..a2b716b1 100644 --- a/experiments/parent-product-acceptance/evidence.test.mjs +++ b/experiments/parent-product-acceptance/evidence.test.mjs @@ -4,7 +4,7 @@ import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { parseDelivered, judgeAudience, buildCase, buildRecord, selfAudit, acceptCase, cleanupFailure } from './evidence.mjs' -import { readJsonl, readTranscript } from './harness.mjs' +import { readJsonl, readTranscript, startupAction } from './harness.mjs' import { openAiterm } from './aiterm.mjs' import { renderDelivery } from '../../skill/scripts/parent-delivery.mjs' @@ -129,3 +129,19 @@ createInterface({ input: process.stdin }).on('line', line => { await aiterm.end() assert.ok(existsSync(dir)) }) + +// 実機で観測した起動画面の抜粋。未知のdialogは押さずにnullを返す。 +test('起動dialogは観測済みの文言だけに操作を返す', () => { + const claudeTrust = ' Quick safety check: Is this a project you created or one you trust?\n ❯ No, exit\n Yes, I trust this folder\n' + const chrome = ' Claude in Chrome extension detected\n ❯ No, keep browser tools off\n Yes, use my browser\n Enter to confirm · Esc to keep browser tools off\n' + const codex155 = ' Do you trust the contents of this directory? Working with untrusted contents\n› 1. Yes, continue\n 2. No, quit\n Press enter to continue\n' + const codex158 = ' Trust this folder? Codex can read, edit, and run files here, subject to your\n› 1. Trust and continue\n 2. Quit\n enter continue · esc quit\n' + assert.deepEqual(startupAction('claude', claudeTrust), { keys: ['Down', 'Enter'], reason: 'claude_folder_trust' }) + assert.deepEqual(startupAction('claude', chrome), { keys: ['Enter'], reason: 'claude_chrome_notice_keep_off' }) + assert.equal(startupAction('claude', chrome.replace('❯ No, keep', ' No, keep').replace(' Yes, use', '❯ Yes, use')), null) + assert.deepEqual(startupAction('codex', codex155), { keys: ['Enter'], reason: 'codex_directory_trust' }) + assert.deepEqual(startupAction('codex', codex158), { keys: ['Enter'], reason: 'codex_directory_trust' }) + assert.equal(startupAction('codex', codex158.replace('› 1. Trust', ' 1. Trust').replace(' 2. Quit', '› 2. Quit')), null) + assert.deepEqual(startupAction('codex', ' ✨ Update available! 0.155.1 -> 0.158.0\n'), { blocked: 'codex_update_prompt' }) + assert.equal(startupAction('codex', ' Some new dialog\n› 1. Accept\n'), null) +}) diff --git a/experiments/parent-product-acceptance/harness.mjs b/experiments/parent-product-acceptance/harness.mjs index 9b6113df..42fe9abc 100644 --- a/experiments/parent-product-acceptance/harness.mjs +++ b/experiments/parent-product-acceptance/harness.mjs @@ -33,11 +33,15 @@ export function startupAction(harness, screen) { if (harness === 'claude') { // 既定選択は「No, exit」。明示的に「Yes, I trust this folder」へ移して確定する。 if (/Quick safety check|Yes, I trust this folder/u.test(screen)) return { keys: ['Down', 'Enter'], reason: 'claude_folder_trust' } + // 初回のChrome拡張通知。既定選択の「No, keep browser tools off」だけを確定し、既存の利用設定を変えない。 + if (/Claude in Chrome extension detected/u.test(screen) && /❯\s*No, keep browser tools off/u.test(screen)) return { keys: ['Enter'], reason: 'claude_chrome_notice_keep_off' } if (/Claude Code v\d/u.test(screen) && /^\s*❯\s/mu.test(screen)) return { ready: true } return null } if (/Update available/u.test(screen)) return { blocked: 'codex_update_prompt' } if (/Do you trust the contents of this directory/u.test(screen) && /›\s*1\. Yes, continue/u.test(screen)) return { keys: ['Enter'], reason: 'codex_directory_trust' } + // codex-cli 0.158の文言。 + if (/Trust this folder\?/u.test(screen) && /›\s*1\. Trust and continue/u.test(screen)) return { keys: ['Enter'], reason: 'codex_directory_trust' } if (/OpenAI Codex \(v/u.test(screen) && /^\s*›\s/mu.test(screen) && !/Press enter to continue/u.test(screen)) return { ready: true } return null } diff --git a/experiments/parent-product-acceptance/run.mjs b/experiments/parent-product-acceptance/run.mjs index 34aa035e..791bae19 100644 --- a/experiments/parent-product-acceptance/run.mjs +++ b/experiments/parent-product-acceptance/run.mjs @@ -111,6 +111,10 @@ try { const closed = await aiterm.close(pty); await aiterm.end(); await sleep(1500) return { session: pty, outcome: closed.outcome, pane_pid: paneOwner?.pid ?? null, pane_alive_after: paneOwner ? isAlive(paneOwner.pid) : null } }) + // pane identityは起動前に取る。起動で失敗しても後片付けでpane残存を判定できるようにする(POSIXはexecで同じPIDがharnessになる)。 + const pane = await aiterm.observe(pty) + if (!pane.pane_process?.pid) fail('ACCEPTANCE_PANE_IDENTITY_MISSING', `pty_observeがpane processを返しません: ${pty}`) + paneOwner = { pid: pane.pane_process.pid, started: pane.pane_process.started_identity } await aiterm.send(pty, launchLine(harness, { project: projectDir, tokenFile, model: args.model })) const dialogs = [] let lastScreen = '' @@ -120,9 +124,6 @@ try { if (action?.keys) { dialogs.push(action.reason); for (const key of action.keys) { await aiterm.key(pty, key); await sleep(600) } await sleep(1500); return false } return action?.ready }, 120000, 1500).catch(error => { writeFileSync(join(privateDir, 'startup-screen.txt'), lastScreen); throw error }) - const pane = await aiterm.observe(pty) - if (!pane.pane_process?.pid) fail('ACCEPTANCE_PANE_IDENTITY_MISSING', `pty_observeがpane processを返しません: ${pty}`) - paneOwner = { pid: pane.pane_process.pid, started: pane.pane_process.started_identity } log('harness_ready', { pty, dialogs, pane_process: pane.pane_process }) if (harness === 'codex') cleanup('codex_folder_trust_remove', async () => { // 試験dirの信頼entryは起動dialogが作ったもの。基準に無かった試験dir配下のkeyだけを公式APIで消す。 From 5462810bfb5c7ad92640006d404723e44d50c33c Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:46:02 +0900 Subject: [PATCH 18/72] =?UTF-8?q?Windows=E3=81=AENode=E7=B5=82=E4=BA=86?= =?UTF-8?q?=E9=9A=9C=E5=AE=B3=E3=81=A8=E5=85=AC=E5=BC=8F=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E3=81=AE=E8=A6=B3=E6=B8=AC=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rag/INDEX.md | 1 + .../codex-hook-body-encoding.md | 2 + .../windows-node-exit-observation.json | 47 +++++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 rag/parent-delivery/windows-node-exit-observation.json diff --git a/rag/INDEX.md b/rag/INDEX.md index 142b4311..c83d5e37 100644 --- a/rag/INDEX.md +++ b/rag/INDEX.md @@ -2,3 +2,4 @@ - [親セッションの公式受信口](parent-delivery/receivers.md) — Aitermの親返信4方式、Peertableへの適用、3 OSで未検証の条件、公式仕様と固定sourceの参照。 - [Codexの公式hook本文の符号化](parent-delivery/codex-hook-body-encoding.md) — 公式XMLの一度の復号と実機本文照合の境界。 +- [WindowsのNode終了エラーの観測](parent-delivery/windows-node-exit-observation.json) — CIの内部assertion、上流修理、公式installerによる更新とfocused確認。 diff --git a/rag/parent-delivery/codex-hook-body-encoding.md b/rag/parent-delivery/codex-hook-body-encoding.md index 2d2bf352..7b480694 100644 --- a/rag/parent-delivery/codex-hook-body-encoding.md +++ b/rag/parent-delivery/codex-hook-body-encoding.md @@ -1,5 +1,7 @@ # Codexの公式hook本文の符号化 +出典は下記のOpenAI公式source。2026-09-29取得。符号化と復号の契約はsourceで確認済みで、実機照合の適用範囲はPeertableの受入判断である。 + ## 確認対象 Codex CLI 0.155.1の[公式実装 `protocol/src/items.rs`](https://raw.githubusercontent.com/openai/codex/rust-v0.155.1/codex-rs/protocol/src/items.rs)を確認した。`HookPromptXml`はhook本文をXMLのtextへ置き、`build_hook_prompt_message`と`serialize_hook_prompt`は`quick_xml`で符号化する。`parse_hook_prompt_fragment`は逆変換を行い、公式のround-trip testも往復を確認する。AitermのCodex受信hookもPeertableと同じ公式hook出力を使う。 diff --git a/rag/parent-delivery/windows-node-exit-observation.json b/rag/parent-delivery/windows-node-exit-observation.json new file mode 100644 index 00000000..9ec3c335 --- /dev/null +++ b/rag/parent-delivery/windows-node-exit-observation.json @@ -0,0 +1,47 @@ +{ + "schema": "peertable.parent-runtime-observation.v1", + "observed_at": "2026-09-29", + "source_commit": "eab935cfa0f3b47388bed8f65fd6951e9e57681c", + "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", + "os": "win32", + "host": "FOX", + "product_live_acceptance": false, + "failed_ci": { + "url": "https://github.com/kitepon/peertable/actions/runs/36459298039/job/109053580490", + "case": "親終了後のwatcherは所有索引を撤去し、project削除後も別の親の相関を壊さない", + "exit_code": 3221226505, + "stderr": "Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\\win\\async.c, line 94" + }, + "before": { + "node_version": "v24.19.0", + "isolated_product_focused_test": { "passed": 1, "failed": 0 }, + "minimal_fetch_process_exit_reproductions": { "attempts": 5, "assertion_failures": 0 } + }, + "external_source": { + "release_notes": "https://nodejs.org/en/blog/release/v24.20.0", + "fix": "https://github.com/nodejs/node/pull/61999", + "issue": "https://github.com/nodejs/node/issues/56645", + "confidence": "同じassertionの上流修理を一次資料で確認。実機の最小再現では未再現のため、CI障害との因果は更新後のCI結果でも照合する" + }, + "installation": { + "method": "Node公式MSI", + "url": "https://nodejs.org/dist/v24.20.0/node-v24.20.0-x64.msi", + "sha256": "28b69132c35ccc033bf8f2a67cd10c9d75ef5822593363309da448f2afff2d8a", + "hash_manifest": "https://nodejs.org/dist/v24.20.0/SHASUMS256.txt", + "authenticode_status": "Valid", + "signer": "OpenJS Foundation", + "exit_code": 0, + "reboot_requested": false, + "restart_manager": "Disable", + "winget_source_updated": true, + "winget_available_version": "24.19.0", + "post_install_probe": "確認コマンドのパスescapeが不正で失敗。installerは再実行せずJoin-Pathで版とfocused testを再確認" + }, + "after": { + "node_version": "v24.20.0", + "isolated_product_focused_test": { "passed": 1, "failed": 0, "skipped": 0 }, + "ci_failed_job_rerun": "requested" + }, + "product_source_changed_for_node_assertion": false, + "interpretation": "配送の照合失敗ではない。Nodeの上流修理を公式installerで適用し、Windowsの失敗jobだけ再検証する。旧版のassertionを成功として扱わない" +} From 43348b8c3557e8abe43e0ee45d3c9b8b19e427f8 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:46:57 +0900 Subject: [PATCH 19/72] =?UTF-8?q?Node=E6=9B=B4=E6=96=B0=E5=BE=8C=E3=81=AEW?= =?UTF-8?q?indows=20CI=E5=90=88=E6=A0=BC=E3=82=92=E8=A6=B3=E6=B8=AC?= =?UTF-8?q?=E8=A8=98=E9=8C=B2=E3=81=B8=E5=8F=8D=E6=98=A0=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rag/parent-delivery/windows-node-exit-observation.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rag/parent-delivery/windows-node-exit-observation.json b/rag/parent-delivery/windows-node-exit-observation.json index 9ec3c335..a59050b0 100644 --- a/rag/parent-delivery/windows-node-exit-observation.json +++ b/rag/parent-delivery/windows-node-exit-observation.json @@ -40,7 +40,9 @@ "after": { "node_version": "v24.20.0", "isolated_product_focused_test": { "passed": 1, "failed": 0, "skipped": 0 }, - "ci_failed_job_rerun": "requested" + "ci_failed_job_rerun": "passed", + "ci_url": "https://github.com/kitepon/peertable/actions/runs/36459298039", + "native_os_ci": { "darwin": "passed", "linux": "passed", "win32": "passed" } }, "product_source_changed_for_node_assertion": false, "interpretation": "配送の照合失敗ではない。Nodeの上流修理を公式installerで適用し、Windowsの失敗jobだけ再検証する。旧版のassertionを成功として扱わない" From 2e6a28666d71a801c4b4845d2bbea70f2e11da01 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:51:21 +0900 Subject: [PATCH 20/72] =?UTF-8?q?=E8=A6=AA=E9=85=8D=E9=80=8124=E3=82=B7?= =?UTF-8?q?=E3=83=8A=E3=83=AA=E3=82=AA=E3=81=AE=E5=AE=9F=E6=A9=9F=E6=89=8B?= =?UTF-8?q?=E9=A0=86=E3=81=A8=E5=B0=82=E7=94=A8=E9=9A=9C=E5=AE=B3=E8=A6=B3?= =?UTF-8?q?=E6=B8=AC=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scenarios-context.mjs | 440 ++++++++++++++++++ .../parent-product-acceptance/scenarios.mjs | 107 +++++ .../scenarios.test.mjs | 94 ++++ 3 files changed, 641 insertions(+) create mode 100644 experiments/parent-product-acceptance/scenarios-context.mjs create mode 100644 experiments/parent-product-acceptance/scenarios.mjs create mode 100644 experiments/parent-product-acceptance/scenarios.test.mjs diff --git a/experiments/parent-product-acceptance/scenarios-context.mjs b/experiments/parent-product-acceptance/scenarios-context.mjs new file mode 100644 index 00000000..7e543c04 --- /dev/null +++ b/experiments/parent-product-acceptance/scenarios-context.mjs @@ -0,0 +1,440 @@ +// 配布物と試験親を使う実操作。既存runnerのclosureを受け取るが、障害操作と判定はこのmoduleが所有する。 +import { createServer, request } from 'node:http' +import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, copyFileSync } from 'node:fs' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { randomUUID } from 'node:crypto' +import { judgeAudience, sha256 } from './evidence.mjs' +import { readTranscript, transcriptPath } from './harness.mjs' +import { scenarioPlan, scenarioNames } from './scenarios.mjs' + +const fail = (code, detail) => { throw Object.assign(new Error(detail), { code }) } +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) +const until = async (label, probe, ms = 300000, every = 1000) => { + const deadline = Date.now() + ms + for (;;) { const value = await probe(); if (value) return value; if (Date.now() >= deadline) fail('ACCEPTANCE_TIMEOUT', label); await sleep(every) } +} + +// 試験roomの手前だけに置くproxy。SSE切断とreceipt HTTP失敗を製品を改造せず実境界へ注入する。 +// runnerの投稿APIはbackendへ向け、親のsetup-state.server_urlだけをproxyのURLへ向ける。 +export async function createScenarioProxy({ backend, artifact }) { + const sockets = new Set(), sourceResponses = new Set(), upstreams = new Set() + const events = []; let sourceBlocked = false, receiptBlocked = false + const note = (kind, fields = {}) => { const row = { at: new Date().toISOString(), kind, ...fields }; events.push(row); if (artifact) appendFileSync(artifact, JSON.stringify(row) + '\n'); return row } + const source = path => /^\/api\/[^/]+\/(?:events|messages|summary)(?:\?|$)/u.test(path) + const server = createServer((req, res) => { + const sourceRequest = req.method === 'GET' && source(req.url) + note('request', { method: req.method, path: req.url, source: sourceRequest }) + if ((sourceBlocked && sourceRequest) || (receiptBlocked && req.method === 'POST' && /\/deliveries(?:\?|$)/u.test(req.url))) { + const kind = sourceRequest ? 'source_http_failed' : 'receipt_http_failed' + res.writeHead(503, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'acceptance_owned_boundary_failure' })); note(kind, { path: req.url }); return + } + const target = new URL(req.url, backend) + const upstream = request(target, { method: req.method, headers: req.headers }, response => { + res.writeHead(response.statusCode, response.headers) + if (sourceRequest) { sourceResponses.add(res); res.once('close', () => sourceResponses.delete(res)) } + response.pipe(res); response.once('error', error => { note('upstream_read_failed', { code: error.code }); res.destroy(error) }) + }) + upstreams.add(upstream); upstream.once('close', () => upstreams.delete(upstream)) + upstream.once('error', error => { note('upstream_failed', { code: error.code }); if (!res.headersSent) res.writeHead(502); res.end() }) + res.once('close', () => { if (!res.writableEnded) upstream.destroy() }); req.pipe(upstream) + }) + server.on('connection', socket => { sockets.add(socket); socket.once('close', () => sockets.delete(socket)) }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + return { + url: `http://127.0.0.1:${server.address().port}`, artifact, events, + disconnectSource() { sourceBlocked = true; const interrupted = sourceResponses.size; for (const res of sourceResponses) res.destroy(); return note('source_disconnected', { interrupted }) }, + reconnectSource() { sourceBlocked = false; return note('source_restored') }, + failReceipts() { receiptBlocked = true; return note('receipt_blocked') }, + restoreReceipts() { receiptBlocked = false; return note('receipt_restored') }, + async close() { sourceBlocked = false; receiptBlocked = false; for (const upstream of upstreams) upstream.destroy(); for (const socket of sockets) socket.destroy(); await new Promise(resolve => server.close(resolve)); note('proxy_closed') }, + } +} + +// parent_readの公式tool出力をraw JSONLから取得する。本文はJSON decodeだけで、任意の置換をしない。 +export function nativeReadPages(harness, file, deliveryId) { + const rows = readFileSync(file, 'utf8').split('\n'); rows.pop() + const pages = []; let turn = null + const text = value => typeof value === 'string' ? value : Array.isArray(value) ? value.map(part => part.text ?? '').join('') : '' + for (const [order, line] of rows.entries()) { + const row = JSON.parse(line), payload = row.payload ?? {} + if (harness === 'claude' && row.type === 'user' && row.promptId) turn = row.promptId + if (harness === 'codex' && (row.type === 'turn_context' || (row.type === 'event_msg' && ['task_started', 'turn_started'].includes(payload.type)))) turn = payload.turn_id ?? turn + let outputs = [] + if (harness === 'claude' && row.type === 'user') outputs = (Array.isArray(row.message?.content) ? row.message.content : []).filter(part => part.type === 'tool_result').map(part => text(part.content)) + if (harness === 'codex' && row.type === 'response_item' && ['function_call_output', 'custom_tool_call_output'].includes(payload.type)) outputs = [text(payload.output)] + for (const output of outputs) { + const candidates = [output] + // MCPの公式text block包装だけを読む。未知包装を正規表現で本文へ変換しない。 + try { const outer = JSON.parse(output); for (const part of outer.content ?? []) if (part.type === 'text') candidates.push(part.text); if (outer.structuredContent) candidates.push(JSON.stringify(outer.structuredContent)) } catch (error) { if (!(error instanceof SyntaxError)) throw error } + for (const candidate of candidates) { + let value; try { value = JSON.parse(candidate) } catch (error) { if (error instanceof SyntaxError) continue; throw error } + const page = value.schema === 'peertable.parent-read-page.v1' ? value : value.schema === 'peertable.parent-read-result.v1' ? value.page : null + if (page?.delivery_id === deliveryId && !pages.some(item => item.order === order && item.page.offset === page.offset)) pages.push({ order, turn_id: turn, session: row.sessionId ?? null, page }) + } + } + } + return pages +} + +export function assembleNativePages(pages) { + if (!pages.length) return null + let offset = 0, body = '' + const first = pages[0].page + for (const { page } of pages) { + if (page.offset !== offset || page.delivery_id !== first.delivery_id || page.digest !== first.digest || page.total_characters !== first.total_characters || typeof page.event?.body !== 'string') fail('ACCEPTANCE_PAGE_NATIVE_SEQUENCE', '実tool出力pageの順序・identityが不一致です') + body += page.event.body; offset += page.event.body.length + if (page.complete !== (offset === page.total_characters)) fail('ACCEPTANCE_PAGE_NATIVE_FINAL', '最終pageのcompleteが原文長と不一致です') + } + return pages.at(-1).page.complete && offset === first.total_characters ? { body, first, final: pages.at(-1) } : null +} + +export async function createScenarioContext(options) { + const { meta, spool, api, observe, submit, file, pkg, projectDir, privateDir, proxy, bin, nativeActions = {}, screen } = options + const importInstalled = name => import(pathToFileURL(join(pkg, 'skill/scripts', name)).href) + const { PAGE_CHARS } = await importInstalled('parent-delivery.mjs') + const { sameProcess, processIdentity, processDescendsFrom, shellCommand } = await importInstalled('parent-platform.mjs') + const recipient = spool.read().name, pending = [], faultState = new Map(), actions = {} + const context = { harness: meta.harness, session: meta.parent_session, pageChars: PAGE_CHARS, actions } + const anchor = () => { const seen = observe(); return seen.replies.at(-1)?.turn_id ?? seen.toolUses.at(-1)?.turn_id ?? seen.turns.at(-1) } + const artifactFor = (scope, label) => { const dir = join(privateDir, 'scenarios', scope.runId, scope.scenario); mkdirSync(dir, { recursive: true, mode: 0o700 }); return join(dir, `${label}.json`) } + const result = (scope, expectation, source, detail = {}) => { + const artifact = artifactFor(scope, `${scope.step_index ?? randomUUID()}-${expectation}`) + const evidence = { kind: expectation, run_id: scope.runId, scenario: scope.scenario, parent_session: meta.parent_session, turn_id: anchor(), source, artifact, ...detail } + if (!evidence.turn_id) fail('ACCEPTANCE_NATIVE_TURN_MISSING', expectation) + writeFileSync(artifact, JSON.stringify(evidence, null, 2)) + return { expectation, verified: true, observations: [evidence] } + } + const recordFor = posted => spool.read().records.find(item => item.event.seq === posted.seq) + const receiptFor = async posted => (await api(`deliveries?seq=${posted.seq}`)).delivery[recipient] + const heartbeat = async () => (await api('members')).bridges?.parent_receiver?.endpoints?.find(endpoint => endpoint.endpoint_id === spool.id)?.beat_at + const check = async (posted, scope) => { + await until(`${scope.scenario}:${posted.nonce} 実親の返答`, async () => { + const record = recordFor(posted), receipt = await receiptFor(posted) + return record?.state === 'submitted' && receipt?.state === 'delivered' && observe().replies.some(reply => reply.session === meta.parent_session && reply.text.includes(posted.nonce)) + }, options.deliveryTimeout ?? 300000) + const seen = await until('会話末尾確定', () => { const value = observe(); return value.pending_tail === 0 ? value : null }, 30000) + const record = recordFor(posted), receipt = await receiptFor(posted) + let deliveries = seen.deliveries + const preview = deliveries.find(item => item.delivery_id === record.delivery_id && item.preview) + const pages = nativeReadPages(meta.harness, file, record.delivery_id) + if (preview || pages.length) { + const assembled = assembleNativePages(pages) + if (!assembled) fail('ACCEPTANCE_NATIVE_WHOLE_BODY_MISSING', `${scope.scenario}: parent_read最終pageがありません`) + // previewと全文pageを別の本文として数えない。実tool出力の最終pageを全文受信の位置にする。 + deliveries = [...deliveries.filter(item => item.delivery_id !== record.delivery_id), { ...(preview ?? {}), room: posted.room, from: assembled.first.event.from ?? assembled.first.event.message?.from, to: assembled.first.event.to_names ?? assembled.first.event.to ?? assembled.first.event.message?.to_names ?? assembled.first.event.message?.to, seq: assembled.first.event.seq, body: assembled.body, delivery_id: record.delivery_id, session: meta.parent_session, turn_id: assembled.final.turn_id, order: assembled.final.order, preview: false, boundary: { encoding: 'none', raw_body_equal: true, native_pages: pages.length } }] + } + const judged = judgeAudience({ label: posted.label, posted, record, receipt, deliveries, replies: seen.replies, session: meta.parent_session, recipient }) + copyFileSync(file, artifactFor(scope, `${posted.nonce}-transcript.jsonl`)) + return { ...judged, nonce: posted.nonce, scenario: scope.scenario, run_id: scope.runId, harness: meta.harness } + } + const post = async (input, scope) => { + const posted = { ...(await api('messages', { from: 'probe', to: recipient, body: input.body })), room: meta.room, nonce: input.nonce, label: input.label } + pending.push({ posted, scope: { ...scope }, defer: input.defer, allowRetained: input.allowRetained }) + return posted + } + actions.deliver = async (input, scope) => { + if (input.endpoint) fail('ACCEPTANCE_NATIVE_ENDPOINT_ADAPTER_MISSING', `関連する実親の操作が未接続です: ${input.endpoint}`) + const posted = await post(input, scope) + if (input.defer || input.allowRetained) { + if (!['source_reconnect', 'process_recovery'].includes(scope.scenario)) await until('製品の原文保存', () => recordFor(posted)?.event.body === posted.body, 45000) + const observed = result(scope, 'native_delivery', 'http_boundary', { deferred: true, seq: posted.seq, posted_body_sha256: sha256(posted.body), receipt: await receiptFor(posted) }) + // deferは配送合格ではない。後続pending_recoverの実親本文checkが必須。 + return observed + } + const judged = await check(posted, scope) + return { ...result(scope, 'native_delivery', 'harness_transcript', { seq: posted.seq, delivery_id: judged.delivery?.delivery_id }), check: judged } + } + actions.pending_recover = async (_, scope) => { + const selected = pending.filter(item => item.scope.runId === scope.runId && item.scope.scenario === scope.scenario && item.defer && !item.recovered) + if (!selected.length) fail('ACCEPTANCE_PENDING_NATIVE_MISSING', scope.scenario) + const checks = [] + for (const item of selected) { checks.push(await check(item.posted, scope)); item.recovered = true } + return { ...result(scope, 'native_delivery', 'harness_transcript', { seqs: checks.map(item => item.original.seq) }), checks } + } + if (['claude', 'codex'].includes(meta.harness)) { + actions.work_start = async (_, scope) => { + const script = artifactFor(scope, 'work-process.mjs'), identity = artifactFor(scope, 'work-start.json'), release = artifactFor(scope, 'work-release.json'), completed = artifactFor(scope, 'work-completed.json') + const nonce = `PEERTABLE_WORK_${randomUUID()}` + writeFileSync(script, `import {writeFileSync,existsSync} from 'node:fs';writeFileSync(process.argv[2],JSON.stringify({pid:process.pid,started_at:new Date().toISOString()}));while(!existsSync(process.argv[3]))await new Promise(r=>setTimeout(r,100));writeFileSync(process.argv[4],JSON.stringify({pid:process.pid,completed_at:new Date().toISOString()}));console.log(process.argv[5]);\n`) + const command = shellCommand(process.execPath, [script, identity, release, completed, nonce]) + const before = observe().rows + faultState.set(`${scope.runId}:work`, { owner: null, identity, release, completed, nonce, before }) + const tool = meta.harness === 'claude' ? 'Bashのrun_in_background=true' : 'exec_commandのyield_time_ms=1000' + await submit(`作業継続の実機受入です。${tool}で次のcommandを実行してください。試験専用processはroom配送中も待機し、runnerが解除すると終了します。終了後に符号${nonce}を報告してください。Peertable配送の符号を受信したら、この作業を中止せず報告してください。command: ${command}`) + await until('実親が開始した作業process', () => existsSync(identity), 120000) + const owner = processIdentity(JSON.parse(readFileSync(identity, 'utf8')).pid) + if (!owner || !sameProcess(owner) || !processDescendsFrom(owner, meta.parent_process) || existsSync(completed)) fail('ACCEPTANCE_WORK_NATIVE_OWNER', '実親が所有する存命の作業processを確認できません') + const seen = observe() + if (!seen.toolUses.some(use => use.order >= before && (meta.harness === 'claude' ? /Bash/u : /exec_command/u).test(use.name))) fail('ACCEPTANCE_WORK_NATIVE_TOOL', '公式toolの作業開始が実会話にありません') + faultState.set(`${scope.runId}:work`, { owner, identity, release, completed, nonce, before, started_order: seen.rows }) + return result(scope, 'work_running', 'os_process', { owner, work_artifact: identity, start_transcript_rows: before }) + } + actions.work_finish = async (_, scope) => { + const work = faultState.get(`${scope.runId}:work`), delivery = scope.checks.at(-1) + if (!work || !sameProcess(work.owner) || existsSync(work.completed) || !delivery || delivery.delivery.order < work.before) fail('ACCEPTANCE_WORK_NOT_CONTINUED', '配送中に元作業を継続していません') + const held = processIdentity(work.owner.pid) + writeFileSync(work.release, JSON.stringify({ released_at: new Date().toISOString(), delivery_seq: delivery.original.seq })) + await until('元作業の完了', () => existsSync(work.completed), 10000) + const reply = await until('作業継続の実親応答', () => observe().replies.find(item => item.text.includes(work.nonce) && item.order > delivery.delivery.order), 120000) + if (reply.session !== meta.parent_session) fail('ACCEPTANCE_WORK_SESSION_CHANGED', '元作業の完了は別会話です') + return result(scope, 'work_continued', 'harness_transcript', { live_at_delivery: held, completion_artifact: work.completed, work_reply_turn: reply.turn_id, delivery_turn: delivery.delivery.turn_id }) + } + } + actions.idle_wait = async (_, scope) => { + const seen = observe(), count = seen.replies.length + if (!count || !screen) fail('ACCEPTANCE_IDLE_ADAPTER_MISSING', '実TUIと最終応答の待機観測が必要です') + const last = seen.replies.at(-1) + await until('実親idle', async () => { + const view = await screen(), current = observe() + const prompt = meta.harness === 'claude' ? /^\s*❯\s/mu : /^\s*›\s/mu + return prompt.test(view) && current.replies.at(-1)?.order === last.order + }, 120000, 1000) + // promptが作業中にも表示される実装があるため、native task終了の記録も必要にする。 + const raw = readFileSync(file, 'utf8') + if (meta.harness === 'codex' && !raw.includes('task_complete')) fail('ACCEPTANCE_IDLE_NATIVE_END_MISSING', 'Codexのtask_completeを確認できません') + faultState.set(`${scope.runId}:idle`, { turn: last.turn_id, order: last.order, rows: seen.rows }) + return result(scope, 'idle_without_input', 'harness_transcript', { idle_turn: last.turn_id, reply_order: last.order }) + } + actions.idle_observe = async (_, scope) => { + const before = faultState.get(`${scope.runId}:idle`), latest = scope.checks.at(-1) + if (!before || !latest || latest.reply.turn_id === before.turn || latest.delivery.order <= before.order) fail('ACCEPTANCE_IDLE_WAKE_MISSING', '待機後にオーナー入力なしで新turnへ起床した記録がありません') + return result(scope, 'idle_new_turn_without_input', 'harness_transcript', { before_turn: before.turn, wake_turn: latest.reply.turn_id, owner_inputs_sent: 0 }) + } + actions.sequence_observe = async (_, scope) => { + if (scope.checks.length < 2 || scope.checks.some((item, index) => index && (item.original.seq <= scope.checks[index - 1].original.seq || item.delivery.order <= scope.checks[index - 1].delivery.order))) fail('ACCEPTANCE_SEQ_ORDER', scope.scenario) + return result(scope, 'seq_order', 'harness_transcript', { sequences: scope.checks.map(item => ({ seq: item.original.seq, order: item.delivery.order, turn: item.delivery.turn_id })) }) + } + actions.policy_set = async (input, scope) => { + const nonce = `PEERTABLE_POLICY_${randomUUID()}` + const instruction = input.tools === 'none' ? '以後は全toolを使わず、room配送を受けたら符号を報告してください。' : input.tools === 'parent_only' ? '以後の外部作業toolは使わず、Peertableの受信登録・parent_readだけを許可します。' : '以後は必要な公式toolを使用してよいです。Peertableの受信を継続してください。' + await submit(`${instruction} 指示を受け取ったら ${nonce} とだけ返答してください。`) + const reply = await until('tool条件の実親確認', () => observe().replies.find(item => item.text.includes(nonce))) + faultState.set(`${scope.runId}:policy:${input.tools}`, { order: reply.order, turn: reply.turn_id, tool_count: observe().toolUses.length }) + return result(scope, 'policy_installed', 'harness_transcript', { policy: input.tools, reply_order: reply.order, policy_turn: reply.turn_id }) + } + actions.tools_observe = async (_, scope) => { + const mode = scope.scenario === 'no_tools' ? 'none' : 'parent_only', before = faultState.get(`${scope.runId}:policy:${mode}`) + const used = observe().toolUses.filter(item => item.order > before.order && item.name !== 'output') + if (mode === 'none' ? used.length : used.some(item => !/parent_(?:join|read|leave)/u.test(item.name))) fail('ACCEPTANCE_TOOL_POLICY_VIOLATED', JSON.stringify(used.map(item => item.name))) + if (mode === 'none') { + const saved = spool.read(), sent = pending.filter(item => item.scope.runId === scope.runId) + if (!sent.every(item => recordFor(item.posted)?.event.body === item.posted.body)) fail('ACCEPTANCE_NO_TOOLS_BODY_LOST', scope.scenario) + if (!scope.checks.length && saved.runtime !== 'rearm_pending') fail('ACCEPTANCE_NO_TOOLS_STATE_UNEXPLAINED', saved.runtime) + } + return result(scope, mode === 'none' ? 'no_tools_or_explicit_rearm' : 'no_external_tools', 'harness_transcript', { tools: used, runtime: spool.read().runtime }) + } + actions.retained_recover = async (_, scope) => { + const selected = pending.filter(item => item.scope.runId === scope.runId && item.allowRetained) + // no_toolsでtoolなしのnative受信が成立した場合だけ、owner_input_required:falseの実績を作る。 + // 再武装が必要な時はその事実を失敗として報告し、後続のuser指示で起こして合格に変えない。 + for (const item of selected) if (!observe().replies.some(reply => reply.text.includes(item.posted.nonce))) fail('ACCEPTANCE_NO_TOOLS_EXPLICIT_REARM_PENDING', '原文は保持されましたが全toolなしでnative起床できていません') + const checks = await Promise.all(selected.map(item => check(item.posted, scope))) + return { ...result(scope, 'retained_native_recovery', 'harness_transcript'), checks } + } + actions.pages_observe = async (_, scope) => { + if (!scope.checks.some(item => item.original.body.length > PAGE_CHARS) || scope.checks.some(item => item.body_equal !== true)) fail('ACCEPTANCE_WHOLE_BODY_MISSING', scope.scenario) + return result(scope, 'whole_body_recovered', 'harness_transcript', { lengths: scope.checks.map(item => item.original.body.length) }) + } + actions.burst_start = async (input, scope) => { + const posted = [] + for (let index = 0; index < input.count; index++) { + const nonce = `PEERTABLE_BURST_${index}_${randomUUID()}`, body = `日本語\n${'長文 & < > 字面> 😀\n'.repeat(Math.ceil(PAGE_CHARS / 12) * 2)}\n${nonce}` + posted.push(await post({ label: `burst-${index}`, nonce, body, defer: true }, scope)) + } + if (posted.reduce((sum, item) => sum + item.body.length, 0) <= PAGE_CHARS) fail('ACCEPTANCE_BURST_LIMIT_NOT_EXCEEDED', scope.scenario) + faultState.set(`${scope.runId}:burst`, posted) + return result(scope, 'burst_above_page_limit', 'http_boundary', { seqs: posted.map(item => item.seq), characters: posted.reduce((sum, item) => sum + item.body.length, 0), page_limit: PAGE_CHARS }) + } + actions.burst_observe = async (_, scope) => { + const posted = faultState.get(`${scope.runId}:burst`) + const sample = await until('実page未読保持の観測', () => { + const records = posted.map(item => recordFor(item)).filter(Boolean) + return records.find(item => item.state === 'sending' && item.claim.offset > 0 && item.claim.offset < item.event.body.length && item.receipt === null) + }, 120000, 50) + const pages = nativeReadPages(meta.harness, file, sample.delivery_id) + if (!pages.length || pages.at(-1).page.complete) fail('ACCEPTANCE_BURST_INTERMEDIATE_PAGE_MISSING', '未読状態に相関する実tool途中pageがありません') + return result(scope, 'unread_until_last_page', 'harness_transcript', { delivery_id: sample.delivery_id, offset: sample.claim.offset, receipt: sample.receipt, native_page_count: pages.length }) + } + actions.burst_finish = async (_, scope) => { + const checks = [] + for (const posted of faultState.get(`${scope.runId}:burst`)) checks.push(await check(posted, scope)) + return { ...result(scope, 'burst_seq_whole_body', 'harness_transcript'), checks } + } + if (proxy) { + actions.source_disconnect = async (_, scope) => { + const before = proxy.events.length, disconnected = proxy.disconnectSource() + faultState.set(`${scope.runId}:source`, { before, disconnected }) + // 実watcherのSSEが開いている場合は切断を直接観測できる。開いていなければ次の実HTTP失敗を待つ。 + if (!disconnected.interrupted) await until('watcherのHTTP失敗', () => proxy.events.slice(before).some(row => row.kind === 'source_http_failed'), 45000) + return result(scope, 'http_sse_disconnected', 'http_boundary', { proxy_artifact: proxy.artifact, disconnected }) + } + actions.source_reconnect = async (_, scope) => { + const before = proxy.events.length; proxy.reconnectSource() + await until('source catch-up', () => proxy.events.slice(before).some(row => row.kind === 'request' && row.source), 45000) + return result(scope, 'http_sse_catchup', 'http_boundary', { proxy_artifact: proxy.artifact, request_events: proxy.events.slice(before) }) + } + actions.receipt_fail_arm = async (_, scope) => { faultState.set(`${scope.runId}:receipt`, { before: proxy.events.length }); proxy.failReceipts(); return result(scope, 'own_receipt_http_failure', 'http_boundary', { proxy_artifact: proxy.artifact }) } + actions.receipt_failure_observe = async (_, scope) => { + const selected = pending.filter(item => item.scope.runId === scope.runId && item.defer).at(-1) + const sample = await until('本文出力後receipt失敗', () => { const saved = recordFor(selected.posted); return saved?.state === 'submitted' && saved.receipt?.pending && observe().replies.some(reply => reply.text.includes(selected.posted.nonce)) ? saved : null }) + const failures = proxy.events.slice(faultState.get(`${scope.runId}:receipt`).before).filter(row => row.kind === 'receipt_http_failed') + if (!failures.length) fail('ACCEPTANCE_RECEIPT_BOUNDARY_FAILURE_MISSING', scope.scenario) + faultState.get(`${scope.runId}:receipt`).sample = sample + return result(scope, 'native_output_receipt_pending', 'harness_transcript', { delivery_id: sample.delivery_id, receipt_revision: sample.receipt.receipt_revision, failure_count: failures.length, proxy_artifact: proxy.artifact }) + } + actions.receipt_restore = async (_, scope) => { + proxy.restoreReceipts(); const before = faultState.get(`${scope.runId}:receipt`).sample + await until('receiptだけ復旧', () => { const item = spool.read().records.find(record => record.delivery_id === before.delivery_id); return item.receipt?.pending === false && item.receipt.receipt_revision === before.receipt.receipt_revision }) + return result(scope, 'receipt_only_recovered', 'http_boundary', { proxy_artifact: proxy.artifact, receipt_revision: before.receipt.receipt_revision }) + } + actions.receipt_retry_observe = async (_, scope) => { + const check = scope.checks.at(-1), before = faultState.get(`${scope.runId}:receipt`).sample + if (check.count !== 1 || check.receipt.receipt_revision !== before.receipt.receipt_revision || recordFor({ seq: check.original.seq }).claim.id !== before.claim.id) fail('ACCEPTANCE_RECEIPT_BODY_RESENT', scope.scenario) + return result(scope, 'output_once_receipt_revision', 'harness_transcript', { delivery_id: before.delivery_id, claim_id: before.claim.id, revision: check.receipt.receipt_revision }) + } + } + actions.watcher_stop = async (_, scope) => { + const saved = spool.read(), owner = saved.watcher + if (!owner || !sameProcess(owner) || owner.pid === meta.parent_process.pid || !processDescendsFrom(owner, meta.parent_process)) fail('ACCEPTANCE_FIXTURE_PROCESS_OWNER', '試験親のwatcher所有を確認できません') + process.kill(owner.pid, 'SIGTERM'); await until('watcher終了', () => !sameProcess(owner), 10000, 100) + faultState.set(`${scope.runId}:watcher`, { owner, cursor: saved.cursor, runtime: saved.runtime }) + return result(scope, 'own_watcher_stopped', 'os_process', { owner, cursor: saved.cursor }) + } + actions.watcher_rejoin = async (_, scope) => { + const before = faultState.get(`${scope.runId}:watcher`), retained = pending.filter(item => item.scope.runId === scope.runId && item.defer) + const records = retained.map(item => recordFor(item.posted)) + // 製品の正規watch開始を親の同joinから行う。runnerから擬似親watcherを起動しない。 + await submit(`Peertable parent_joinをproject=${projectDir} name=${recipient}で同じ会話から一度だけ呼び、既存受信を再開してください。配送確認の符号を正確に報告してください。`) + const current = await until('製品watcher再起動', () => { const saved = spool.read(); return saved.watcher && saved.watcher.pid !== before.owner.pid && sameProcess(saved.watcher) ? saved : null }) + if (current.endpoint_id !== spool.id || current.cursor < before.cursor || current.caller.conversation !== meta.parent_session) fail('ACCEPTANCE_READY_RECOVERY_IDENTITY', '同じendpoint/cursor/親会話を継承しませんでした') + return result(scope, 'own_watcher_restarted', 'os_process', { before_owner: before.owner, new_owner: current.watcher, before_cursor: before.cursor, cursor: current.cursor, retained: records.map(item => ({ delivery_id: item?.delivery_id ?? null, state: item?.state ?? null })) }) + } + const armInterruption = (scope, { afterDelete = false, includeWatcher = false } = {}) => { + const baseline = new Set(spool.read().records.map(record => record.delivery_id)) + const monitor = { completed: false, canceled: false, interrupted: null, error: null } + monitor.promise = (async () => { + const deadline = Date.now() + 120000 + while (!monitor.canceled && Date.now() < deadline) { + const record = spool.read().records.find(item => !baseline.has(item.delivery_id) && item.state === 'sending' && (!afterDelete || item.hook_state === 'deleted')) + if (record) { + const owner = record.hook_claim?.owner ?? record.claim?.owner + const watcher = spool.read().watcher + if (owner && owner.pid !== meta.parent_process.pid && (includeWatcher || owner.pid !== watcher?.pid) && sameProcess(owner) && processDescendsFrom(owner, meta.parent_process)) { + const now = spool.read().records.find(item => item.delivery_id === record.delivery_id) + if (now.state !== 'sending' || (afterDelete && now.hook_state !== 'deleted')) { await sleep(1); continue } + const at = new Date().toISOString() + process.kill(owner.pid, 'SIGKILL') + monitor.interrupted = { record: now, owner, at, after_delete: afterDelete } + monitor.completed = true + return monitor.interrupted + } + } + await sleep(1) + } + if (!monitor.canceled) monitor.error = Object.assign(new Error('実claimの中断可能な区間を観測できませんでした'), { code: 'ACCEPTANCE_CLAIM_BOUNDARY_MISSED' }) + monitor.completed = true + return null + })().catch(error => { monitor.error = error; monitor.completed = true; return null }) + faultState.set(`${scope.runId}:interrupt`, monitor) + return monitor + } + actions.output_interrupt_arm = async (_, scope) => { + if (meta.harness === 'codex') { + // delete後の出力を観測するので、nativeの同期hookを通った場合だけfaultが成立する。 + // idle queueだけで完了した場合はboundary_missedで止める。 + armInterruption(scope, { afterDelete: true }) + } else armInterruption(scope) + return result(scope, 'own_output_boundary_armed', 'os_process', { endpoint_id: spool.id, target_parent: meta.parent_process, codex_after_delete: meta.harness === 'codex' }) + } + actions.output_interrupt = async (_, scope) => { + const monitor = faultState.get(`${scope.runId}:interrupt`) + await until('実出力processの中断', () => monitor.completed, 125000, 10) + if (monitor.error) throw monitor.error + if (!monitor.interrupted) fail('ACCEPTANCE_CLAIM_BOUNDARY_MISSED', scope.scenario) + const interrupted = monitor.interrupted + await until('中断後unknown receipt', async () => { + const record = spool.read().records.find(item => item.delivery_id === interrupted.record.delivery_id) + const receipt = await receiptFor({ seq: record.event.seq }) + return record.state === 'unknown' && record.event.body === interrupted.record.event.body && receipt?.state === 'unknown' + }, 45000) + const record = spool.read().records.find(item => item.delivery_id === interrupted.record.delivery_id) + faultState.set(`${scope.runId}:unknown`, { record, at_rows: observe().rows }) + return result(scope, 'claim_output_unknown_preserved', 'os_process', { interrupted_owner: interrupted.owner, killed_at: interrupted.at, delivery_id: record.delivery_id, body_sha256: sha256(record.event.body), error_code: record.error_code, receipt_revision: record.receipt.receipt_revision, after_delete: interrupted.after_delete }) + } + actions.output_unknown_observe = async (_, scope) => { + const before = faultState.get(`${scope.runId}:unknown`), initial = before.record + // sourceの次のhealth/receipt周期を跨ぎ、実会話への自動再送が無いことを確認する。 + // 初回unknownの記録だけで再送なしを断定しない。 + const initialSource = await heartbeat() + if (!initialSource) fail('ACCEPTANCE_SOURCE_HEARTBEAT_MISSING', scope.scenario) + const nextSource = await until('unknown後の製品維持周期', async () => { const at = await heartbeat(); return at && at > initialSource ? at : null }, 60000) + const record = spool.read().records.find(item => item.delivery_id === initial.delivery_id), seen = observe() + if (record.state !== 'unknown' || record.event.body !== initial.event.body || record.claim.id !== initial.claim.id || record.receipt.receipt_revision !== initial.receipt.receipt_revision || seen.deliveries.some(item => item.delivery_id === initial.delivery_id && item.order >= before.at_rows)) fail('ACCEPTANCE_UNKNOWN_RESENT_OR_LOST', scope.scenario) + return result(scope, 'unknown_not_resent', 'harness_transcript', { delivery_id: record.delivery_id, initial_source_progress: initialSource, next_source_progress: nextSource, observed_rows: seen.rows, initial_rows: before.at_rows, body_sha256: sha256(record.event.body), receipt_revision: record.receipt.receipt_revision }) + } + actions.sending_interrupt = async (_, scope) => { + const nonce = `PEERTABLE_SENDING_INTERRUPT_${randomUUID()}` + const monitor = armInterruption(scope, { includeWatcher: true }) + await post({ label: 'sending中断', nonce, body: `日本語 sending原文\n${nonce}`, defer: true }, scope) + await until('実sending processの中断', () => monitor.completed, 125000, 10) + if (monitor.error) throw monitor.error + if (monitor.interrupted?.owner.pid === spool.read().watcher?.pid) { + await submit(`同じPeertable parent_joinをproject=${projectDir} name=${recipient}で一度だけ呼び、実watcherの孤児claim回収を継続してください。`) + await until('中断後の実watcher再起動', () => { const owner = spool.read().watcher; return owner && owner.pid !== monitor.interrupted.owner.pid && sameProcess(owner) }) + } + await actions.output_interrupt({}, scope) + await actions.output_unknown_observe({}, scope) + return result(scope, 'sending_unknown_no_resend', 'harness_transcript', { interrupted: monitor.interrupted, receipt: await receiptFor({ seq: monitor.interrupted.record.event.seq }) }) + } + actions.submitted_restart = async (_, scope) => { + const submitted = scope.checks.at(-1) + if (!submitted) fail('ACCEPTANCE_SUBMITTED_BASELINE_MISSING', scope.scenario) + const record = recordFor({ seq: submitted.original.seq }), before = { claim: record.claim.id, receipt: record.receipt.receipt_revision, count: observe().deliveries.filter(item => item.delivery_id === record.delivery_id).length } + const owner = spool.read().watcher + if (owner && sameProcess(owner)) { + if (owner.pid === meta.parent_process.pid || !processDescendsFrom(owner, meta.parent_process)) fail('ACCEPTANCE_FIXTURE_PROCESS_OWNER', 'watcherの所有が不一致です') + process.kill(owner.pid, 'SIGTERM'); await until('watcher停止', () => !sameProcess(owner), 10000, 100) + } + await submit(`同じPeertable parent_joinをproject=${projectDir} name=${recipient}で一度だけ呼び、既存受信を継続してください。`) + await until('watcher再起動', () => { const current = spool.read().watcher; return current && sameProcess(current) && current.pid !== owner?.pid }) + const firstBeat = await heartbeat() + await until('再起動後の製品維持周期', async () => { const at = await heartbeat(); return at && at > firstBeat }, 60000) + const after = recordFor({ seq: submitted.original.seq }), count = observe().deliveries.filter(item => item.delivery_id === record.delivery_id).length + if (after.state !== 'submitted' || after.claim.id !== before.claim || after.receipt.receipt_revision !== before.receipt || count !== before.count) fail('ACCEPTANCE_SUBMITTED_RESENT', scope.scenario) + return result(scope, 'submitted_not_resubmitted', 'harness_transcript', { delivery_id: record.delivery_id, before, after_count: count, new_watcher: spool.read().watcher }) + } + actions.package_inspect = async (_, scope) => { + const state = spool.read(), paths = [state.watcher?.executable, ...(state.wait_receipt?.args ?? [])].filter(Boolean) + if (meta.package_version !== JSON.parse(readFileSync(join(pkg, 'package.json'), 'utf8')).version || !meta.package_tarball_sha256 || paths.some(path => path.includes('/Developer/peertable/') || path.includes('/worktrees/'))) fail('ACCEPTANCE_PACKAGE_CHECKOUT_DEPENDENCY', '配布物の版・tarball・実receiver pathが不一致です') + const watcher = state.watcher && processIdentity(state.watcher.pid) + if (!watcher?.command.includes(join(pkg, 'skill/scripts/parent-watch.mjs'))) fail('ACCEPTANCE_PACKAGE_NATIVE_ENTRY_MISSING', '実watcherが導入物のentryを使用していません') + return result(scope, 'pack_installed_no_checkout_paths', 'installed_package', { tarball_sha256: meta.package_tarball_sha256, version: meta.package_version, runtime_digest: meta.runtime_digest, native_watcher: watcher, installed_root: pkg }) + } + actions.package_observe = async (_, scope) => { + if (!scope.checks.length || scope.checks.at(-1).status !== 'passed') fail('ACCEPTANCE_PACKAGE_NATIVE_DELIVERY_MISSING', scope.scenario) + return result(scope, 'pack_native_entry_used', 'harness_transcript', { installed_root: pkg, endpoint_id: spool.id }) + } + // 専用のnative adapterは親の所有を検証して操作し、その実artifactを返す。 + // callbackが無い境界はrunScenarioが事前にtyped errorで停止する。 + for (const [name, action] of Object.entries(nativeActions)) { + if (actions[name]) fail('ACCEPTANCE_ACTION_OVERRIDE', `既存の実操作を上書きできません: ${name}`) + actions[name] = (input, scope) => action(input, { ...scope, meta, spool, api, observe, submit, file, pkg, projectDir, privateDir, result, pending, check, post, faultState, sameProcess, processIdentity, processDescendsFrom, shellCommand }) + } + context.finalize = async scope => { + const monitor = faultState.get(`${scope.runId}:interrupt`) + if (monitor) { monitor.canceled = true; await monitor.promise } + const work = faultState.get(`${scope.runId}:work`) + if (work) { + // releaseはこの試験が作ったprocessだけが読む。startに失敗しても後発のchildを待機させない。 + writeFileSync(work.release, JSON.stringify({ released_at: new Date().toISOString(), cleanup: true })) + if (work.owner && sameProcess(work.owner)) { + try { await until('試験作業processの後片付け', () => !sameProcess(work.owner), 10000, 100) } + catch (error) { if (error.code !== 'ACCEPTANCE_TIMEOUT') throw error; if (sameProcess(work.owner)) process.kill(work.owner.pid, 'SIGKILL') } + } + } + } + context.supported = scenarioNames.filter(name => scenarioPlan(name, { pageChars: PAGE_CHARS }).every(item => typeof actions[item.action] === 'function')) + context.missing = Object.fromEntries(scenarioNames.filter(name => !context.supported.includes(name)).map(name => [name, [...new Set(scenarioPlan(name, { pageChars: PAGE_CHARS }).map(item => item.action))].filter(action => !actions[action])])) + return context +} diff --git a/experiments/parent-product-acceptance/scenarios.mjs b/experiments/parent-product-acceptance/scenarios.mjs new file mode 100644 index 00000000..b6aa2ee4 --- /dev/null +++ b/experiments/parent-product-acceptance/scenarios.mjs @@ -0,0 +1,107 @@ +// 実機受入24scenarioの手順。公式親の会話と実境界の観測が揃うまで合格を作らない。 +import { randomUUID } from 'node:crypto' +import { isDeepStrictEqual } from 'node:util' +import { scenarios as inventory } from '../../scripts/parent-delivery-acceptance.mjs' + +export const scenarioNames = Object.keys(inventory).filter(name => name !== 'audience') +const fail = (code, detail) => { throw Object.assign(new Error(detail), { code }) } +const step = (action, expectation, input = {}) => ({ action, expectation, input }) +const send = (label = '本文', input = {}) => step('deliver', 'native_delivery', { label, ...input }) +const native = (action, expectation, input = {}) => step(action, expectation, input) + +// 各adapterは同じ目的を自harnessの公式境界で実施する。未知の境界を類似APIへ置換しない。 +export const receiverBoundaries = { + claude: { active: '実sessionのPostToolUse/Stop asyncRewake', claim: '実hook processのclaimとstderr exit2', output: 'asyncRewakeのstderr出力', background: '実sessionのhook process', foreign: '利用者hookと承認', lease: 'asyncRewake有限leaseと同join再武装', switch: '/clear・/exit・公式resume' }, + codex: { active: '実threadの同期PostToolUse/Stop', claim: '所有queueのdelete:trueと実hook process', output: 'additionalContext/block reasonのstdout出力', background: '公式thread queueと接続', foreign: '同threadの利用者/Aiterm所有queueとhook', lease: 'queue接続更新', switch: '/new・/quit・公式resume' }, + cursor: { active: '実conversationのadditional_context', claim: '公式hookとnative背景Shellの共通claim', output: 'hook stdoutと背景Shell完了', background: '親所有native Shell/task', foreign: '利用者hookと承認', lease: '背景task有限leaseと次登録', switch: '公式新会話・終了・resume' }, + grok: { active: '実sessionのrun_terminal_command背景完了', claim: '背景processとparent_readの共通claim', output: '完了task出力とparent_read最終page', background: '親所有run_terminal_command task', foreign: '利用者hookと承認', lease: '背景task有限leaseと次登録', switch: '公式新会話・終了・resume' }, +} + +// 文字数上限は導入物のPAGE_CHARSをcontextから受け取る。上限をrunnerに複製しない。 +export function scenarioPlan(name, { pageChars } = {}) { + const plans = { + busy: [native('work_start', 'work_running'), send(), native('work_finish', 'work_continued')], + final_race: [native('final_arm', 'final_boundary_armed'), send('終了境界'), native('final_observe', 'final_no_loss_duplicate')], + idle: [native('idle_wait', 'idle_without_input'), send('待機から起床'), native('idle_observe', 'idle_new_turn_without_input')], + consecutive: [send('1通目'), native('idle_wait', 'idle_without_input'), send('2通目'), native('work_start', 'work_running'), send('3通目'), native('work_finish', 'work_continued'), native('sequence_observe', 'seq_order')], + no_external_tools: [native('policy_set', 'policy_installed', { tools: 'parent_only' }), send('外部作業toolなし'), native('tools_observe', 'no_external_tools')], + no_tools: [native('policy_set', 'policy_installed', { tools: 'none' }), send('全toolなし', { allowRetained: true }), native('tools_observe', 'no_tools_or_explicit_rearm'), native('policy_set', 'policy_installed', { tools: 'normal' }), native('retained_recover', 'retained_native_recovery')], + original: [send('日本語・引用・改行', { body: `日本語😀\n「引用」 'q' "double" & 字面>\n\\path\\改行\r\n末尾\n` }), send('長文全量', { long: true }), native('pages_observe', 'whole_body_recovered')], + burst: [native('burst_start', 'burst_above_page_limit', { count: 4 }), native('burst_observe', 'unread_until_last_page'), native('burst_finish', 'burst_seq_whole_body')], + source_reconnect: [native('source_disconnect', 'http_sse_disconnected'), send('切断中', { defer: true }), native('source_reconnect', 'http_sse_catchup'), native('pending_recover', 'native_delivery'), send('再接続後'), native('sequence_observe', 'seq_order')], + process_recovery: [native('receiver_suspend', 'own_receiver_suspended'), send('ready保持', { defer: true }), native('receiver_restart', 'ready_inherited'), native('pending_recover', 'native_delivery'), native('sending_interrupt', 'sending_unknown_no_resend'), native('submitted_restart', 'submitted_not_resubmitted'), send('復旧後')], + claim_race: [native('claim_race_arm', 'own_claim_competitors'), send('claim競合'), native('claim_race_observe', 'single_claim_output')], + output_interruption: [native('output_interrupt_arm', 'own_output_boundary_armed'), send('中断対象', { defer: true }), native('output_interrupt', 'claim_output_unknown_preserved'), native('output_unknown_observe', 'unknown_not_resent'), send('中断後別本文')], + receipt_retry: [native('receipt_fail_arm', 'own_receipt_http_failure'), send('receipt失敗', { defer: true }), native('receipt_failure_observe', 'native_output_receipt_pending'), native('receipt_restore', 'receipt_only_recovered'), native('pending_recover', 'native_delivery'), native('receipt_retry_observe', 'output_once_receipt_revision')], + session_change: [send('旧会話正常'), native('session_clear', 'new_conversation_identity'), send('旧宛保持', { defer: true, endpoint: 'old' }), native('session_new_join', 'new_binding_verified'), send('新会話正常', { endpoint: 'new' }), native('session_resume', 'official_resume_identity'), native('session_isolation_observe', 'old_body_no_new_conversation'), send('resume後', { endpoint: 'resumed' })], + parallel_rooms: [native('parallel_open', 'two_rooms_two_native_conversations'), send('第一room'), send('第二room', { endpoint: 'parallel' }), native('parallel_observe', 'claims_room_conversation_separated')], + unavailable_hook: [native('hook_disable', 'own_hook_disabled'), native('join_expect_failure', 'disabled_hook_typed_failure'), native('hook_untrust', 'own_hook_untrusted'), native('join_expect_failure', 'untrusted_hook_typed_failure'), native('hook_remove_file', 'own_hook_file_absent'), native('join_expect_failure', 'absent_hook_typed_failure'), native('hook_restore', 'normal_launch_intact'), send('hook復元後')], + foreign_ownership: [native('foreign_seed', 'own_fixture_foreign_entries'), native('foreign_snapshot', 'foreign_order_approval_baseline'), send('所有分離'), native('foreign_observe', 'foreign_entries_unchanged')], + compatibility_hooks: [native('compatibility_arm', 'own_compatible_hook_loaded'), send('互換hook'), native('compatibility_observe', 'one_adapter_one_binding')], + background_end: [native('background_cancel', 'native_task_cancel_observed'), native('background_timeout', 'native_task_timeout_observed'), native('background_exit', 'native_task_exit_observed'), native('background_observe', 'task_end_not_delivered'), native('receiver_rearm', 'native_receiving_rearmed'), send('背景終了後')], + lease: [native('lease_expiry_arm', 'own_finite_lease'), send('期限前'), native('lease_expiry_observe', 'finite_lease_control_not_body'), send('再武装待ち', { defer: true }), native('receiver_rearm', 'same_endpoint_cursor_rearmed'), native('pending_recover', 'native_delivery'), send('期限更新後')], + binding_deadline: [native('binding_timeout_arm', 'own_binding_pending'), native('binding_timeout_observe', 'binding_timeout_typed_failed'), native('probe_timeout_arm', 'own_probe_not_consumed'), native('probe_timeout_observe', 'probe_timeout_typed_failed'), native('deadline_restore', 'new_join_verified'), send('期限障害復旧後')], + slot_race: [native('slot_race_arm', 'own_simultaneous_hooks'), send('slot競合'), native('slot_race_observe', 'single_live_slot'), send('slot継続')], + lifecycle: [send('更新前'), native('same_version_update', 'same_version_single_registration'), native('new_version_update', 'new_version_history_binding_preserved'), send('更新後'), native('teardown', 'receiver_stopped_parent_alive_history_kept'), native('lifecycle_rejoin', 'new_binding_after_teardown'), send('再導入後')], + package: [native('package_inspect', 'pack_installed_no_checkout_paths'), send('導入物配送'), native('package_observe', 'pack_native_entry_used')], + } + if (!scenarioNames.includes(name)) fail('ACCEPTANCE_SCENARIO_UNKNOWN', `正本にないscenarioです: ${name}`) + if (['original', 'burst'].includes(name) && (!Number.isSafeInteger(pageChars) || pageChars <= 0)) fail('ACCEPTANCE_PAGE_LIMIT_MISSING', '導入物のPAGE_CHARSが必要です') + return plans[name] +} + +// 観測はその試験専用artifactを指す。spoolだけ、runner自身のstdout、fixtureだけではnative合格にしない。 +export function validateBoundary(result, { expectation, session, runId, scenario }) { + if (result?.expectation !== expectation || result?.verified !== true) fail('ACCEPTANCE_BOUNDARY_FAILED', `${scenario}: ${expectation}を実測で確認できません`) + if (!Array.isArray(result.observations) || !result.observations.length) fail('ACCEPTANCE_NATIVE_OBSERVATION_MISSING', `${expectation}の観測がありません`) + for (const row of result.observations) { + if (!row.kind || row.run_id !== runId || row.scenario !== scenario || !row.turn_id || !row.parent_session || !row.artifact || !['harness_transcript', 'official_queue', 'official_hook', 'native_task', 'os_process', 'http_boundary', 'installed_package'].includes(row.source)) fail('ACCEPTANCE_BOUNDARY_EVIDENCE_INVALID', `${expectation}: 実境界の相関・artifactが不足しています`) + if (row.parent_session !== session && !result.related_sessions?.includes(row.parent_session)) fail('ACCEPTANCE_BOUNDARY_SESSION_MISMATCH', `${expectation}: 別会話の観測です`) + } + return result.observations +} + +export function validateNativeCheck(check, { scenario, runId, session }) { + if (check?.run_id !== runId || check?.scenario !== scenario || check.status !== 'passed' || check.body_equal !== true || check.count !== 1 || !check.original || !check.received || !isDeepStrictEqual(check.original, check.received)) fail('ACCEPTANCE_NATIVE_BODY_FAILED', `${scenario}: 原文/実会話本文の一致・1回配送を確認できません`) + if (check.delivery?.session !== session || !check.delivery.turn_id || check.reply?.session !== session || !check.reply.turn_id || check.reply.order <= check.delivery.order || !check.reply.text.includes(check.nonce)) fail('ACCEPTANCE_NATIVE_REPLY_MISSING', `${scenario}: 同じ実会話の後続応答がありません`) + if (check.receipt?.result !== 'delivered' || !Number.isSafeInteger(check.receipt.receipt_revision) || check.receipt.receipt_revision <= 0) fail('ACCEPTANCE_NATIVE_RECEIPT_MISSING', `${scenario}: 宛先別receiptがありません`) + if (check.harness === 'codex' && (!check.receipt.queued_submission_id || !check.receipt.accepted_at)) fail('ACCEPTANCE_CODEX_QUEUE_RECEIPT_MISSING', `${scenario}: 公式queueの受付証拠がありません`) + if (check.delivery.boundary?.encoding && !['none', 'codex_hook_prompt_xml_text'].includes(check.delivery.boundary.encoding)) fail('ACCEPTANCE_BODY_NORMALIZATION_FORBIDDEN', '任意の本文正規化は許可されていません') + return check +} + +// contextは導入物・実親・専用roomの操作を所有する。action未提供時は実行前にtyped errorで止める。 +export async function runScenario(name, context) { + const { harness, session, pageChars, runId = randomUUID() } = context + if (!receiverBoundaries[harness]) fail('ACCEPTANCE_HARNESS_UNSUPPORTED', harness) + const plan = scenarioPlan(name, { pageChars }) + const missing = [...new Set(plan.map(item => item.action))].filter(action => typeof context.actions?.[action] !== 'function') + if (missing.length) fail('ACCEPTANCE_SCENARIO_ADAPTER_MISSING', `${harness}/${name}: ${missing.join(', ')}`) + const checks = [], observations = [], trace = [], nonces = new Set() + const scope = { scenario: name, runId, session, harness, pageChars, checks, observations, boundaries: receiverBoundaries[harness] } + try { + for (const [index, item] of plan.entries()) { + scope.step_index = index + const nonce = `PEERTABLE_${name.toUpperCase()}_${index}_${randomUUID()}` + const body = item.input.body ?? `日本語の観測\n「引用」😀 & 字面>\n配送確認の符号は ${nonce} です。` + const input = { ...item.input, ...(item.action === 'deliver' ? { nonce, body: `${body}${item.input.long ? ('\n長文 日本語 < > & 字面& 😀'.repeat(Math.ceil(pageChars / 20) * 3)) : ''}\n${nonce}` } : {}), index } + const result = await context.actions[item.action](input, scope) + if (item.action === 'deliver' && !item.input.defer && !item.input.allowRetained) { + const targetSession = result.parent_session ?? session + validateNativeCheck(result.check, { scenario: name, runId, session: targetSession }) + if (nonces.has(result.check.nonce) || result.check.nonce !== nonce) fail('ACCEPTANCE_SCENARIO_EVIDENCE_REUSED', `${name}: 専用の投稿ではありません`) + nonces.add(nonce); checks.push(result.check) + } + observations.push(...validateBoundary(result, { expectation: item.expectation, session, runId, scenario: name })) + if (result.checks) for (const check of result.checks) { + validateNativeCheck(check, { scenario: name, runId, session: check.parent_session ?? session }) + if (nonces.has(check.nonce)) fail('ACCEPTANCE_SCENARIO_EVIDENCE_REUSED', `${name}: 同じ観測を再使用しています`) + nonces.add(check.nonce); checks.push(check) + } + trace.push({ step: index, action: item.action, expectation: item.expectation, artifacts: result.observations.map(row => row.artifact) }) + } + if (!checks.length) fail('ACCEPTANCE_NO_NATIVE_DELIVERY', `${name}: 専用本文の実親受信がありません`) + if (['consecutive', 'source_reconnect', 'burst'].includes(name) && checks.some((check, index) => index && (check.original.seq <= checks[index - 1].original.seq || check.delivery.order <= checks[index - 1].delivery.order))) fail('ACCEPTANCE_SEQ_ORDER', `${name}: seq順に配送されていません`) + return { scenario: name, run_id: runId, checks, observations, trace, boundaries: receiverBoundaries[harness], status: 'passed' } + } finally { if (context.finalize) await context.finalize(scope) } +} diff --git a/experiments/parent-product-acceptance/scenarios.test.mjs b/experiments/parent-product-acceptance/scenarios.test.mjs new file mode 100644 index 00000000..44c685d2 --- /dev/null +++ b/experiments/parent-product-acceptance/scenarios.test.mjs @@ -0,0 +1,94 @@ +// focused testはrunnerの判定と障害proxyを確認する。fixtureの結果をproduct_live証拠へ出さない。 +import test from 'node:test' +import assert from 'node:assert/strict' +import { createServer } from 'node:http' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { scenarios as inventory } from '../../scripts/parent-delivery-acceptance.mjs' +import { scenarioNames, scenarioPlan, runScenario, validateBoundary, validateNativeCheck } from './scenarios.mjs' +import { createScenarioProxy, nativeReadPages, assembleNativePages } from './scenarios-context.mjs' + +const check = () => ({ run_id: 'run', scenario: 'package', status: 'passed', body_equal: true, count: 1, nonce: 'unique', harness: 'claude', original: { room: 'room', from: 'probe', to: 'bell', seq: 1, body: '日本語 & 字面>' }, received: { room: 'room', from: 'probe', to: 'bell', seq: 1, body: '日本語 & 字面>' }, delivery: { session: 'session', turn_id: 'turn', order: 2 }, reply: { session: 'session', turn_id: 'turn2', order: 3, text: 'unique' }, receipt: { result: 'delivered', receipt_revision: 1 } }) +const checkScope = { runId: 'run', scenario: 'package', session: 'session' } + +test('正本のaudience以外24scenarioに専用手順がある', () => { + assert.equal(scenarioNames.length, 24) + assert.deepEqual(scenarioNames, Object.keys(inventory).filter(name => name !== 'audience')) + for (const name of scenarioNames) { + const plan = scenarioPlan(name, { pageChars: 100 }) + assert.ok(plan.length > 1) + assert.ok(plan.every(item => item.action && item.expectation)) + } +}) +test('未知scenarioと導入物のpage上限不足をtyped failureにする', () => { + assert.throws(() => scenarioPlan('fake'), { code: 'ACCEPTANCE_SCENARIO_UNKNOWN' }) + assert.throws(() => scenarioPlan('original'), { code: 'ACCEPTANCE_PAGE_LIMIT_MISSING' }) +}) +test('adapter未接続時は操作前に停止し、成績を作らない', async () => { + let calls = 0 + await assert.rejects(runScenario('busy', { harness: 'codex', session: 'session', pageChars: 100, actions: { deliver: () => { calls++ } } }), { code: 'ACCEPTANCE_SCENARIO_ADAPTER_MISSING' }) + assert.equal(calls, 0) +}) +test('原文と実会話・後続応答・receiptのすべてが必要', () => { + assert.equal(validateNativeCheck(check(), checkScope).status, 'passed') + for (const mutation of [item => { item.received.body = '変更' }, item => { item.delivery.session = '別会話' }, item => { item.reply = null }, item => { item.receipt = null }, item => { item.count = 2 }, item => { item.run_id = '別試験' }]) { + const item = check(); mutation(item); assert.throws(() => validateNativeCheck(item, checkScope)) + } +}) +test('Codex queue受付とXML一度復号以外の本文加工を拒否する', () => { + const item = check(); item.harness = 'codex' + assert.throws(() => validateNativeCheck(item, checkScope), { code: 'ACCEPTANCE_CODEX_QUEUE_RECEIPT_MISSING' }) + Object.assign(item.receipt, { queued_submission_id: 'queue', accepted_at: new Date().toISOString() }) + item.delivery.boundary = { encoding: 'whitespace_normalized' } + assert.throws(() => validateNativeCheck(item, checkScope), { code: 'ACCEPTANCE_BODY_NORMALIZATION_FORBIDDEN' }) +}) +test('spool/fixtureだけの観測や別scenarioのartifactを実機に使わない', () => { + const scope = { ...checkScope, expectation: 'receipt_only_recovered' } + const row = { kind: 'receipt', run_id: 'run', scenario: 'package', parent_session: 'session', turn_id: 'turn', artifact: '/fixture.json', source: 'spool' } + assert.throws(() => validateBoundary({ expectation: scope.expectation, verified: true, observations: [row] }, scope), { code: 'ACCEPTANCE_BOUNDARY_EVIDENCE_INVALID' }) + row.source = 'http_boundary'; row.scenario = 'audience' + assert.throws(() => validateBoundary({ expectation: scope.expectation, verified: true, observations: [row] }, scope), { code: 'ACCEPTANCE_BOUNDARY_EVIDENCE_INVALID' }) +}) +test('最終pageまで順序どおり読み、本文の字面を保つ', () => { + const pages = [ + { page: { delivery_id: 'id', digest: 'digest', offset: 0, total_characters: 7, complete: false, event: { body: '日本語' } } }, + { page: { delivery_id: 'id', digest: 'digest', offset: 3, total_characters: 7, complete: true, event: { body: '>' } } }, + ] + assert.equal(assembleNativePages(pages).body, '日本語>') + assert.equal(assembleNativePages(pages.slice(0, 1)), null) + pages[1].page.offset = 4 + assert.throws(() => assembleNativePages(pages), { code: 'ACCEPTANCE_PAGE_NATIVE_SEQUENCE' }) +}) +test('公式parent_read包装からpageを読み、任意の本文復号をしない', t => { + const dir = mkdtempSync(join(tmpdir(), 'peertable-scenarios-page-')); t.after(() => rmSync(dir, { recursive: true, force: true })) + const file = join(dir, 'fixture.jsonl'), page = { schema: 'peertable.parent-read-page.v1', delivery_id: 'id', digest: 'd', offset: 0, total_characters: 4, complete: true, event: { body: '>' } } + writeFileSync(file, [JSON.stringify({ type: 'turn_context', payload: { turn_id: 't' } }), JSON.stringify({ type: 'response_item', payload: { type: 'function_call_output', output: JSON.stringify({ content: [{ type: 'text', text: JSON.stringify({ schema: 'peertable.parent-read-result.v1', page }) }] }) } }), ''].join('\n')) + const pages = nativeReadPages('codex', file, 'id') + assert.equal(pages.length, 1); assert.equal(pages[0].turn_id, 't'); assert.equal(assembleNativePages(pages).body, '>') +}) +test('fixture proxyで実HTTP source断とreceipt障害を分け、復旧する', async t => { + const backend = createServer((req, res) => { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ ok: true, path: req.url })) }) + await new Promise(resolve => backend.listen(0, '127.0.0.1', resolve)) + const proxy = await createScenarioProxy({ backend: `http://127.0.0.1:${backend.address().port}` }) + t.after(async () => { await proxy.close(); await new Promise(resolve => backend.close(resolve)) }) + assert.equal((await fetch(`${proxy.url}/api/room/messages`)).status, 200) + proxy.disconnectSource() + assert.equal((await fetch(`${proxy.url}/api/room/messages`)).status, 503) + assert.equal((await fetch(`${proxy.url}/api/room/messages`, { method: 'POST', body: '{}' })).status, 200) + proxy.reconnectSource(); proxy.failReceipts() + assert.equal((await fetch(`${proxy.url}/api/room/messages`)).status, 200) + assert.equal((await fetch(`${proxy.url}/api/room/deliveries`, { method: 'POST', body: '{}' })).status, 503) + proxy.restoreReceipts() + assert.equal((await fetch(`${proxy.url}/api/room/deliveries`, { method: 'POST', body: '{}' })).status, 200) + assert.ok(proxy.events.some(item => item.kind === 'source_http_failed')); assert.ok(proxy.events.some(item => item.kind === 'receipt_http_failed')) +}) +test('実行途中のfailureでも専用processのcleanupを呼ぶ', async () => { + let finalized = null + const actions = { + package_inspect: async () => { throw Object.assign(new Error('試験境界'), { code: 'FIXTURE_FAILED' }) }, + deliver: async () => {}, package_observe: async () => {}, + } + await assert.rejects(runScenario('package', { harness: 'claude', session: 'session', runId: 'run', pageChars: 100, actions, finalize: async scope => { finalized = scope } }), { code: 'FIXTURE_FAILED' }) + assert.equal(finalized.scenario, 'package'); assert.equal(finalized.runId, 'run') +}) From a3c58c579cf11b8e0abf1117eaa6dd962f86d636 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:02:02 +0900 Subject: [PATCH 21/72] =?UTF-8?q?=E8=A6=AA=E7=B5=82=E4=BA=86=E6=99=82?= =?UTF-8?q?=E3=81=AE=E7=B4=A2=E5=BC=95=E4=BF=AE=E7=90=86=E3=82=92=E9=80=9A?= =?UTF-8?q?=E5=B8=B8CLI=E3=81=AE=E5=AE=9F=E4=BC=9A=E8=A9=B1=E3=81=A7?= =?UTF-8?q?=E7=A2=BA=E8=AA=8D=E3=81=97=E3=81=9F=E8=A8=98=E9=8C=B2=E3=82=92?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plan_parent-native-delivery.md | 4 + rag/INDEX.md | 1 + .../repaired-cli-candidate-observation.json | 1036 +++++++++++++++++ 3 files changed, 1041 insertions(+) create mode 100644 rag/parent-delivery/repaired-cli-candidate-observation.json diff --git a/docs/plan_parent-native-delivery.md b/docs/plan_parent-native-delivery.md index ec59e3e3..5f27b2d3 100644 --- a/docs/plan_parent-native-delivery.md +++ b/docs/plan_parent-native-delivery.md @@ -375,3 +375,7 @@ GUI準備では3 OSのCodex App、macOSとWindowsのCursor Desktopを確認し 実機runnerで、終了したCodex親のwatcherがglobal索引を残す欠陥を再現した。project撤去後に別の親hookが全索引を読むと`ENOENT`で配送が止まる。停止したwatcher自身が既存の`forgetEndpoint`で所有索引を撤去する修理を行い、本文・受付済み記録と他の親の索引を保持する。修正前に失敗した実processの回帰試験は修正後に合格し、関連focused testは28件合格した。`a07630d`で取得したmacOS Claude/Codexのaudience証拠は候補観測として保管し、修理後の最終候補へ流用しない。 Codexの公式hook本文はXMLのtextとして記録される。[一次仕様の確認](../rag/parent-delivery/codex-hook-body-encoding.md)に従い、公式の符号化だけを一度復号して原文と照合する。raw記録の等値判定と復号後の照合を別々に残し、任意の文字列補正を行わない。 + +修理後のmacOS通常CLIで、Claude/CodexのDM・複数宛・ALLについて親の実会話・応答・原文・receiptを再確認し、親終了後の自動停止と共有索引の撤去、他設定の保持を確認した。[修理後の候補観測](../rag/parent-delivery/repaired-cli-candidate-observation.json)は実測commitをそのまま記録し、最終manifestへ流用しない。 + +最終受入の製品sourceは`43348b8c3557e8abe43e0ee45d3c9b8b19e427f8`へ揃えて測定する。試験制御用の`experiments/`は配布物に含めず、実測した製品sourceとは別にrunnerのcommitを記録する。異なるcommitで得た証拠の`source_commit`を書き換えず、最終対象で実行する。実機で確認した起動dialogと終了確認のrunner修理は統合済みで、関連focused testは9件合格した。 diff --git a/rag/INDEX.md b/rag/INDEX.md index c83d5e37..15f2815d 100644 --- a/rag/INDEX.md +++ b/rag/INDEX.md @@ -3,3 +3,4 @@ - [親セッションの公式受信口](parent-delivery/receivers.md) — Aitermの親返信4方式、Peertableへの適用、3 OSで未検証の条件、公式仕様と固定sourceの参照。 - [Codexの公式hook本文の符号化](parent-delivery/codex-hook-body-encoding.md) — 公式XMLの一度の復号と実機本文照合の境界。 - [WindowsのNode終了エラーの観測](parent-delivery/windows-node-exit-observation.json) — CIの内部assertion、上流修理、公式installerによる更新とfocused確認。 +- [修理後の通常CLI候補の観測](parent-delivery/repaired-cli-candidate-observation.json) — macOS Claude/Codexの原文・返答・receiptと、親終了後の自動停止・索引撤去・設定復元。 diff --git a/rag/parent-delivery/repaired-cli-candidate-observation.json b/rag/parent-delivery/repaired-cli-candidate-observation.json new file mode 100644 index 00000000..8b392f2b --- /dev/null +++ b/rag/parent-delivery/repaired-cli-candidate-observation.json @@ -0,0 +1,1036 @@ +{ + "schema": "peertable.parent-candidate-observation.v1", + "source": "専用通常CLIのpublic実会話証拠と終了後のproduct state。親ベルが本文・receipt・索引撤去・設定復元を再照合した。", + "retrieved_at": "2026-09-29", + "confidence": "この2候補試験の配送と終了処理は実測確認済み。最終525件の受入と他OS・他実行面は未確認。", + "status": "candidate_observation_not_final_acceptance", + "source_commit": "42f72b3672873a61a9195476baaf3e1432488ac6", + "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", + "package_version": "0.8.63", + "final_acceptance_source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8", + "reuse_in_final_manifest": false, + "cases": [ + { + "harness": "claude", + "evidence_sha256": "9bdf9d7527fd470724c641e79e349dd78cff1dbe16c8ee0970e15ffb45a5cbd9", + "evidence": { + "schema": "peertable.parent-live-case.v1", + "case_id": "darwin/claude/cli/audience", + "status": "passed", + "reasons": [], + "native": true, + "os": "darwin", + "harness": "claude", + "surface": "cli", + "source_commit": "42f72b3672873a61a9195476baaf3e1432488ac6", + "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", + "package_version": "0.8.63", + "client_version": "0.8.63", + "package_tarball_sha256": "55f7f8789b24f4a316b9b67414b5e96a63a2422110085215dcb5b485c58e6fc6", + "harness_version": "2.1.283 (Claude Code)", + "node_version": "v26.10.0", + "parent_session": "babe5f0c-0551-4cde-9feb-05e468957fb6", + "parent_process": { + "pid": 71137, + "parent": 1199, + "started": "Tue Sep 29 02:44:15 2026", + "executable": "/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe", + "command": "claude" + }, + "endpoint_id": "3f79a83d-750a-49c7-8458-15f4e75e2feb", + "room": "parent-acceptance-claude-2bd3e5ab-e8f1-4606-b30c-441dc5350afb", + "owner_input_required": false, + "isolation": { + "home": "normal", + "room": "local_private_room_server", + "session_args": [], + "folder_trust": "起動dialogで試験dirを信頼(Claudeのproject履歴に残る)" + }, + "observations": [ + { + "kind": "parent_join", + "parent_session": "babe5f0c-0551-4cde-9feb-05e468957fb6", + "turn_id": "896994a8-d1a8-458c-9680-700e43c33e20", + "tool_use_id": "toolu_01JGMFzWvxa7q6uM6v22pGg4", + "tool_name": "mcp__peertable_parent__parent_join", + "endpoint_id": "3f79a83d-750a-49c7-8458-15f4e75e2feb", + "caller_owner": { + "pid": 71137, + "parent": 1199, + "started": "Tue Sep 29 02:44:15 2026", + "executable": "/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe", + "command": "claude" + }, + "call_generation": "1c77697e-0e4d-4169-951d-c03af398b25d" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "dm", + "parent_session": "babe5f0c-0551-4cde-9feb-05e468957fb6", + "turn_id": "75a098cc-f56b-4389-8ee0-2d5296e991c1", + "seq": 3, + "delivery_id": "3827fe8a-e8b7-4cea-9ebc-9e8e5ebd010a", + "entry": { + "uuid": "9c031d48-81cf-4ad2-aca7-a3a62ca427cb", + "type": "user", + "role": null + }, + "native_hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "native_queue": [ + "enqueue" + ], + "native_ack_id": "9c031d48-81cf-4ad2-aca7-a3a62ca427cb" + }, + { + "kind": "parent_reply_after_delivery", + "label": "dm", + "parent_session": "babe5f0c-0551-4cde-9feb-05e468957fb6", + "turn_id": "75a098cc-f56b-4389-8ee0-2d5296e991c1", + "seq": 3, + "entry_uuid": "97bea4d4-869a-47f7-a0b9-ef7845b79e5e" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "multiple", + "parent_session": "babe5f0c-0551-4cde-9feb-05e468957fb6", + "turn_id": "76e91e90-1db4-4545-b7c7-a01a307dfe9d", + "seq": 4, + "delivery_id": "fce9d36a-f3af-49d6-835c-964d5b89e58a", + "entry": { + "uuid": "a8976aa0-ffc3-46ff-bb8b-c021ea960238", + "type": "user", + "role": null + }, + "native_hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "native_queue": [ + "enqueue" + ], + "native_ack_id": "a8976aa0-ffc3-46ff-bb8b-c021ea960238" + }, + { + "kind": "parent_reply_after_delivery", + "label": "multiple", + "parent_session": "babe5f0c-0551-4cde-9feb-05e468957fb6", + "turn_id": "76e91e90-1db4-4545-b7c7-a01a307dfe9d", + "seq": 4, + "entry_uuid": "03a2209b-7281-4ca6-b530-737afac298c8" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "all", + "parent_session": "babe5f0c-0551-4cde-9feb-05e468957fb6", + "turn_id": "51991b1a-2624-4fa7-b5c1-8dbacb68a8a4", + "seq": 5, + "delivery_id": "364846c0-7c23-4dd0-bd85-e5087cd0bf22", + "entry": { + "uuid": "e74fe35c-069b-4ac1-b394-855e81159826", + "type": "user", + "role": null + }, + "native_hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "native_queue": [ + "enqueue" + ], + "native_ack_id": "e74fe35c-069b-4ac1-b394-855e81159826" + }, + { + "kind": "parent_reply_after_delivery", + "label": "all", + "parent_session": "babe5f0c-0551-4cde-9feb-05e468957fb6", + "turn_id": "51991b1a-2624-4fa7-b5c1-8dbacb68a8a4", + "seq": 5, + "entry_uuid": "7c7d23e5-d7f8-4c55-8dcc-cdefa7f5b919" + } + ], + "receipts": [ + { + "seq": 3, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + } + ], + "body_checks": [ + { + "label": "dm", + "original": { + "room": "parent-acceptance-claude-2bd3e5ab-e8f1-4606-b30c-441dc5350afb", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_DM_b152822e-5122-46ce-bad8-f61f89de930b です。" + }, + "received": { + "room": "parent-acceptance-claude-2bd3e5ab-e8f1-4606-b30c-441dc5350afb", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_DM_b152822e-5122-46ce-bad8-f61f89de930b です。" + } + }, + { + "label": "multiple", + "original": { + "room": "parent-acceptance-claude-2bd3e5ab-e8f1-4606-b30c-441dc5350afb", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_922bbd65-4012-4dc0-806b-f8b9ea7b0cf4 です。" + }, + "received": { + "room": "parent-acceptance-claude-2bd3e5ab-e8f1-4606-b30c-441dc5350afb", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_922bbd65-4012-4dc0-806b-f8b9ea7b0cf4 です。" + } + }, + { + "label": "all", + "original": { + "room": "parent-acceptance-claude-2bd3e5ab-e8f1-4606-b30c-441dc5350afb", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_ALL_c8256014-6be1-4521-9f9c-fca547f0a1fe です。" + }, + "received": { + "room": "parent-acceptance-claude-2bd3e5ab-e8f1-4606-b30c-441dc5350afb", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_ALL_c8256014-6be1-4521-9f9c-fca547f0a1fe です。" + } + } + ], + "checks": [ + { + "status": "passed", + "reasons": [], + "label": "dm", + "recipient": "bell", + "original": { + "room": "parent-acceptance-claude-2bd3e5ab-e8f1-4606-b30c-441dc5350afb", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_DM_b152822e-5122-46ce-bad8-f61f89de930b です。" + }, + "received": { + "room": "parent-acceptance-claude-2bd3e5ab-e8f1-4606-b30c-441dc5350afb", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_DM_b152822e-5122-46ce-bad8-f61f89de930b です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-claude-2bd3e5ab-e8f1-4606-b30c-441dc5350afb", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_DM_b152822e-5122-46ce-bad8-f61f89de930b です。", + "delivery_id": "3827fe8a-e8b7-4cea-9ebc-9e8e5ebd010a", + "digest": "abb1a8420afcce112a9e6ef907237492f1cf60739391b45eb4902a679a25cf6c", + "preview": false, + "order": 72, + "session": "babe5f0c-0551-4cde-9feb-05e468957fb6", + "turn_id": "75a098cc-f56b-4389-8ee0-2d5296e991c1", + "hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "entry": { + "uuid": "9c031d48-81cf-4ad2-aca7-a3a62ca427cb", + "type": "user", + "role": null + } + }, + "reply": { + "order": 73, + "session": "babe5f0c-0551-4cde-9feb-05e468957fb6", + "turn_id": "75a098cc-f56b-4389-8ee0-2d5296e991c1", + "entry_uuid": "97bea4d4-869a-47f7-a0b9-ef7845b79e5e", + "text": "4件目の観測データを受信しました。配送確認の符号を含んでいます。\n\n- 符号: PEERTABLE_ACCEPT_CLAUDE_DM_b152822e-5122-46ce-bad8-f61f89de930b\n- 送信者: probe\n- 宛先: bell\n- seq: 3\n- 本文: 「日本語の観測」の後に、引用符・絵文字・`>`・``・`&`・`'q'` などの特殊文字を含む行が続く。\n- room: parent-acceptance-claude-2bd3e5ab-e8f1-4606-b30c-441dc5350afb\n- 配送ID: 3827fe8a-e8b7-4cea-9ebc-9e8e5ebd010a\n\n追加の行動命令はありませんでした。" + }, + "spool": { + "delivery_id": "3827fe8a-e8b7-4cea-9ebc-9e8e5ebd010a", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "claude_asyncRewake_output" + }, + "receipt": { + "seq": 3, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + } + }, + { + "status": "passed", + "reasons": [], + "label": "multiple", + "recipient": "bell", + "original": { + "room": "parent-acceptance-claude-2bd3e5ab-e8f1-4606-b30c-441dc5350afb", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_922bbd65-4012-4dc0-806b-f8b9ea7b0cf4 です。" + }, + "received": { + "room": "parent-acceptance-claude-2bd3e5ab-e8f1-4606-b30c-441dc5350afb", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_922bbd65-4012-4dc0-806b-f8b9ea7b0cf4 です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-claude-2bd3e5ab-e8f1-4606-b30c-441dc5350afb", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_922bbd65-4012-4dc0-806b-f8b9ea7b0cf4 です。", + "delivery_id": "fce9d36a-f3af-49d6-835c-964d5b89e58a", + "digest": "80101c9c047c27db8d7796cae8439a93bd9721133300b48be7cf04a1fe7ff9cd", + "preview": false, + "order": 78, + "session": "babe5f0c-0551-4cde-9feb-05e468957fb6", + "turn_id": "76e91e90-1db4-4545-b7c7-a01a307dfe9d", + "hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "entry": { + "uuid": "a8976aa0-ffc3-46ff-bb8b-c021ea960238", + "type": "user", + "role": null + } + }, + "reply": { + "order": 79, + "session": "babe5f0c-0551-4cde-9feb-05e468957fb6", + "turn_id": "76e91e90-1db4-4545-b7c7-a01a307dfe9d", + "entry_uuid": "03a2209b-7281-4ca6-b530-737afac298c8", + "text": "5件目の観測データを受信しました。配送確認の符号を含んでいます。\n\n- 符号: PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_922bbd65-4012-4dc0-806b-f8b9ea7b0cf4\n- 送信者: probe\n- 宛先: bell、rui(複数宛先)\n- seq: 4\n- 本文: 前回と同じ「日本語の観測」と特殊文字の行。\n- room: parent-acceptance-claude-2bd3e5ab-e8f1-4606-b30c-441dc5350afb\n- 配送ID: fce9d36a-f3af-49d6-835c-964d5b89e58a\n\n追加の行動命令はありませんでした。" + }, + "spool": { + "delivery_id": "fce9d36a-f3af-49d6-835c-964d5b89e58a", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "claude_asyncRewake_output" + }, + "receipt": { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + } + }, + { + "status": "passed", + "reasons": [], + "label": "all", + "recipient": "bell", + "original": { + "room": "parent-acceptance-claude-2bd3e5ab-e8f1-4606-b30c-441dc5350afb", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_ALL_c8256014-6be1-4521-9f9c-fca547f0a1fe です。" + }, + "received": { + "room": "parent-acceptance-claude-2bd3e5ab-e8f1-4606-b30c-441dc5350afb", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_ALL_c8256014-6be1-4521-9f9c-fca547f0a1fe です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-claude-2bd3e5ab-e8f1-4606-b30c-441dc5350afb", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_ALL_c8256014-6be1-4521-9f9c-fca547f0a1fe です。", + "delivery_id": "364846c0-7c23-4dd0-bd85-e5087cd0bf22", + "digest": "2a6f668e616c843b2b19e599df910960697133231299841d381b68e2dff7bb17", + "preview": false, + "order": 84, + "session": "babe5f0c-0551-4cde-9feb-05e468957fb6", + "turn_id": "51991b1a-2624-4fa7-b5c1-8dbacb68a8a4", + "hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "entry": { + "uuid": "e74fe35c-069b-4ac1-b394-855e81159826", + "type": "user", + "role": null + } + }, + "reply": { + "order": 85, + "session": "babe5f0c-0551-4cde-9feb-05e468957fb6", + "turn_id": "51991b1a-2624-4fa7-b5c1-8dbacb68a8a4", + "entry_uuid": "7c7d23e5-d7f8-4c55-8dcc-cdefa7f5b919", + "text": "6件目の観測データを受信しました。配送確認の符号を含んでいます。\n\n- 符号: PEERTABLE_ACCEPT_CLAUDE_ALL_c8256014-6be1-4521-9f9c-fca547f0a1fe\n- 送信者: probe\n- 宛先: all(全員宛)\n- seq: 5\n- 本文: 前回までと同じ「日本語の観測」と特殊文字の行。\n- room: parent-acceptance-claude-2bd3e5ab-e8f1-4606-b30c-441dc5350afb\n- 配送ID: 364846c0-7c23-4dd0-bd85-e5087cd0bf22\n\n追加の行動命令はありませんでした。" + }, + "spool": { + "delivery_id": "364846c0-7c23-4dd0-bd85-e5087cd0bf22", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "claude_asyncRewake_output" + }, + "receipt": { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + } + } + ], + "transcript_rows": 88, + "turns_seen": [ + "896994a8-d1a8-458c-9680-700e43c33e20", + "60051992-fc9a-41d1-9337-d22d61428c50", + "78b92bbb-6844-468a-8470-1843d94b3ef6", + "13d7b4df-da09-48e1-a09e-37f5ded2c685", + "75a098cc-f56b-4389-8ee0-2d5296e991c1", + "76e91e90-1db4-4545-b7c7-a01a307dfe9d", + "51991b1a-2624-4fa7-b5c1-8dbacb68a8a4" + ] + }, + "run_status": "passed", + "errors": [], + "findings": [], + "cleanup": [ + { + "label": "harness_exit", + "status": "done", + "pid": 71137, + "exited": true + }, + { + "label": "endpoint_stop", + "status": "done", + "product_index_left_after_stop": false, + "runtime_before": "stopped", + "runtime_after": "stopped", + "product_stopped_within_30s": true, + "stopped_by_runner": false, + "owned_alive_after": [] + }, + { + "label": "pty_close", + "status": "done", + "session": "ptacc-claude-ef9c2039", + "outcome": "already_closed", + "pane_pid": 71137, + "pane_alive_after": false + }, + { + "label": "room_server", + "status": "done", + "pid": 71043, + "exit": "SIGTERM", + "alive_after": false + }, + { + "label": "connect_remove", + "status": "done", + "removed": [ + "claude:removed" + ], + "semantic_equal": true, + "settings_diff_keys": [], + "mcp_diff_keys": [], + "user_keys_added": [], + "backup": null + } + ] + }, + { + "harness": "codex", + "evidence_sha256": "b468c973f446833264396d37c808e1120059659f904d2dd81c57fc3f689b8655", + "evidence": { + "schema": "peertable.parent-live-case.v1", + "case_id": "darwin/codex/cli/audience", + "status": "passed", + "reasons": [], + "native": true, + "os": "darwin", + "harness": "codex", + "surface": "cli", + "source_commit": "42f72b3672873a61a9195476baaf3e1432488ac6", + "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", + "package_version": "0.8.63", + "client_version": "0.8.63", + "package_tarball_sha256": "55f7f8789b24f4a316b9b67414b5e96a63a2422110085215dcb5b485c58e6fc6", + "harness_version": "codex-cli 0.155.1", + "node_version": "v26.10.0", + "parent_session": "01a0e91e-0c5f-75e1-b14b-30423f8856c9", + "parent_process": { + "pid": 70793, + "parent": 1199, + "started": "Tue Sep 29 02:44:13 2026", + "executable": "~/.codex/packages/standalone/releases/0.155.1-aarch64-apple-darwin/bin/codex", + "command": "codex -c mcp_servers.peertable_parent.env_vars=[\"PEERTABLE_TOKEN_SOURCE_FILE\"] -c check_for_update_on_startup=false" + }, + "endpoint_id": "46882a83-8f42-4296-81ba-0df4be3a9214", + "room": "parent-acceptance-codex-ea9a66d0-7563-4781-9be6-31a13d843833", + "owner_input_required": false, + "isolation": { + "home": "normal", + "room": "local_private_room_server", + "session_args": [ + "-c mcp_servers.peertable_parent.env_vars=[\"PEERTABLE_TOKEN_SOURCE_FILE\"]", + "-c check_for_update_on_startup=false" + ], + "folder_trust": "起動dialogで試験dirを信頼し、終了後に公式config/batchWriteでその projects entry だけを削除" + }, + "observations": [ + { + "kind": "parent_join", + "parent_session": "01a0e91e-0c5f-75e1-b14b-30423f8856c9", + "turn_id": "01a0e91e-211e-7933-8df9-97100f5a4276", + "tool_use_id": "2", + "tool_name": "output", + "endpoint_id": "46882a83-8f42-4296-81ba-0df4be3a9214", + "caller_owner": { + "pid": 70793, + "parent": 1199, + "started": "Tue Sep 29 02:44:13 2026", + "executable": "~/.codex/packages/standalone/releases/0.155.1-aarch64-apple-darwin/bin/codex", + "command": "codex -c mcp_servers.peertable_parent.env_vars=[\"PEERTABLE_TOKEN_SOURCE_FILE\"] -c check_for_update_on_startup=false" + }, + "call_generation": null + }, + { + "kind": "delivery_in_parent_conversation", + "label": "dm", + "parent_session": "01a0e91e-0c5f-75e1-b14b-30423f8856c9", + "turn_id": "01a0e91e-211e-7933-8df9-97100f5a4276", + "seq": 3, + "delivery_id": "213df940-ebcf-4985-ba35-1f6cc9e2f6d4", + "entry": { + "uuid": null, + "type": "response_item:message:user", + "role": "user" + }, + "native_hook": "stop", + "boundary": { + "encoding": "codex_hook_prompt_xml_text", + "raw_body_equal": false + }, + "native_queue": [], + "native_ack_id": "01a0e91e-5440-7a81-be58-a80d920cc128" + }, + { + "kind": "parent_reply_after_delivery", + "label": "dm", + "parent_session": "01a0e91e-0c5f-75e1-b14b-30423f8856c9", + "turn_id": "01a0e91e-211e-7933-8df9-97100f5a4276", + "seq": 3, + "entry_uuid": null + }, + { + "kind": "delivery_in_parent_conversation", + "label": "multiple", + "parent_session": "01a0e91e-0c5f-75e1-b14b-30423f8856c9", + "turn_id": "01a0e91e-a2f7-7211-97a5-54b08d50d383", + "seq": 4, + "delivery_id": "5b415e75-b92e-4f52-bd8d-57d4f48c43c2", + "entry": { + "uuid": null, + "type": "response_item:message:user", + "role": "user" + }, + "native_hook": null, + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "native_queue": [], + "native_ack_id": "01a0e91e-92e3-7840-9f27-27d0bd61ee74" + }, + { + "kind": "parent_reply_after_delivery", + "label": "multiple", + "parent_session": "01a0e91e-0c5f-75e1-b14b-30423f8856c9", + "turn_id": "01a0e91e-a2f7-7211-97a5-54b08d50d383", + "seq": 4, + "entry_uuid": null + }, + { + "kind": "delivery_in_parent_conversation", + "label": "all", + "parent_session": "01a0e91e-0c5f-75e1-b14b-30423f8856c9", + "turn_id": "01a0e91e-ca1f-7ac1-8d1e-cb097fd085f9", + "seq": 5, + "delivery_id": "734cd46a-e2a2-482b-a664-90063889eedc", + "entry": { + "uuid": null, + "type": "response_item:message:user", + "role": "user" + }, + "native_hook": null, + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "native_queue": [], + "native_ack_id": "01a0e91e-ba15-7c60-8ff1-e87112edb0e3" + }, + { + "kind": "parent_reply_after_delivery", + "label": "all", + "parent_session": "01a0e91e-0c5f-75e1-b14b-30423f8856c9", + "turn_id": "01a0e91e-ca1f-7ac1-8d1e-cb097fd085f9", + "seq": 5, + "entry_uuid": null + } + ], + "receipts": [ + { + "seq": 3, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "codex_hook_output_complete", + "receipt_revision": 2, + "queued_submission_id": "01a0e91e-5440-7a81-be58-a80d920cc128", + "accepted_at": "2026-09-28T17:44:34.113Z" + }, + { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "codex_queue_accepted", + "receipt_revision": 1, + "queued_submission_id": "01a0e91e-92e3-7840-9f27-27d0bd61ee74", + "accepted_at": "2026-09-28T17:44:50.148Z" + }, + { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "codex_queue_accepted", + "receipt_revision": 1, + "queued_submission_id": "01a0e91e-ba15-7c60-8ff1-e87112edb0e3", + "accepted_at": "2026-09-28T17:45:00.182Z" + } + ], + "body_checks": [ + { + "label": "dm", + "original": { + "room": "parent-acceptance-codex-ea9a66d0-7563-4781-9be6-31a13d843833", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_DM_5f4f4546-438b-469f-94c8-2773e208150e です。" + }, + "received": { + "room": "parent-acceptance-codex-ea9a66d0-7563-4781-9be6-31a13d843833", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_DM_5f4f4546-438b-469f-94c8-2773e208150e です。" + } + }, + { + "label": "multiple", + "original": { + "room": "parent-acceptance-codex-ea9a66d0-7563-4781-9be6-31a13d843833", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_MULTIPLE_0ad342cf-96ff-4bf8-9099-484933d4c08b です。" + }, + "received": { + "room": "parent-acceptance-codex-ea9a66d0-7563-4781-9be6-31a13d843833", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_MULTIPLE_0ad342cf-96ff-4bf8-9099-484933d4c08b です。" + } + }, + { + "label": "all", + "original": { + "room": "parent-acceptance-codex-ea9a66d0-7563-4781-9be6-31a13d843833", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_ALL_6dd306ae-fe9c-4bfa-acfb-307d5d9135cc です。" + }, + "received": { + "room": "parent-acceptance-codex-ea9a66d0-7563-4781-9be6-31a13d843833", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_ALL_6dd306ae-fe9c-4bfa-acfb-307d5d9135cc です。" + } + } + ], + "checks": [ + { + "status": "passed", + "reasons": [], + "label": "dm", + "recipient": "bell", + "original": { + "room": "parent-acceptance-codex-ea9a66d0-7563-4781-9be6-31a13d843833", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_DM_5f4f4546-438b-469f-94c8-2773e208150e です。" + }, + "received": { + "room": "parent-acceptance-codex-ea9a66d0-7563-4781-9be6-31a13d843833", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_DM_5f4f4546-438b-469f-94c8-2773e208150e です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-codex-ea9a66d0-7563-4781-9be6-31a13d843833", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_DM_5f4f4546-438b-469f-94c8-2773e208150e です。", + "delivery_id": "213df940-ebcf-4985-ba35-1f6cc9e2f6d4", + "digest": "137d28528e7a6d7ca70eb7e4e09dbaf9c900db3e54bf168a6be54dc65af46f19", + "preview": false, + "order": 38, + "session": "01a0e91e-0c5f-75e1-b14b-30423f8856c9", + "turn_id": "01a0e91e-211e-7933-8df9-97100f5a4276", + "hook": "stop", + "boundary": { + "encoding": "codex_hook_prompt_xml_text", + "raw_body_equal": false + }, + "entry": { + "uuid": null, + "type": "response_item:message:user", + "role": "user" + } + }, + "reply": { + "order": 41, + "session": "01a0e91e-0c5f-75e1-b14b-30423f8856c9", + "turn_id": "01a0e91e-211e-7933-8df9-97100f5a4276", + "text": "- 符号:`PEERTABLE_ACCEPT_CODEX_DM_5f4f4546-438b-469f-94c8-2773e208150e`\n- 送信者:`probe`\n- 宛先:`bell`\n- seq:`3`" + }, + "spool": { + "delivery_id": "213df940-ebcf-4985-ba35-1f6cc9e2f6d4", + "state": "submitted", + "queued_submission_id": "01a0e91e-5440-7a81-be58-a80d920cc128", + "accepted_at": "2026-09-28T17:44:34.113Z", + "reason": "codex_hook_output_complete" + }, + "receipt": { + "seq": 3, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "codex_hook_output_complete", + "receipt_revision": 2, + "queued_submission_id": "01a0e91e-5440-7a81-be58-a80d920cc128", + "accepted_at": "2026-09-28T17:44:34.113Z" + } + }, + { + "status": "passed", + "reasons": [], + "label": "multiple", + "recipient": "bell", + "original": { + "room": "parent-acceptance-codex-ea9a66d0-7563-4781-9be6-31a13d843833", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_MULTIPLE_0ad342cf-96ff-4bf8-9099-484933d4c08b です。" + }, + "received": { + "room": "parent-acceptance-codex-ea9a66d0-7563-4781-9be6-31a13d843833", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_MULTIPLE_0ad342cf-96ff-4bf8-9099-484933d4c08b です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-codex-ea9a66d0-7563-4781-9be6-31a13d843833", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_MULTIPLE_0ad342cf-96ff-4bf8-9099-484933d4c08b です。", + "delivery_id": "5b415e75-b92e-4f52-bd8d-57d4f48c43c2", + "digest": "37f967069cf824737a1870ee1cb20420487517db14599098c3a1acfe419222ed", + "preview": false, + "order": 47, + "session": "01a0e91e-0c5f-75e1-b14b-30423f8856c9", + "turn_id": "01a0e91e-a2f7-7211-97a5-54b08d50d383", + "hook": null, + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "entry": { + "uuid": null, + "type": "response_item:message:user", + "role": "user" + } + }, + "reply": { + "order": 50, + "session": "01a0e91e-0c5f-75e1-b14b-30423f8856c9", + "turn_id": "01a0e91e-a2f7-7211-97a5-54b08d50d383", + "text": "- 符号:`PEERTABLE_ACCEPT_CODEX_MULTIPLE_0ad342cf-96ff-4bf8-9099-484933d4c08b`\n- 送信者:`probe`\n- 宛先:`[\"bell\",\"rui\"]`\n- seq:`4`" + }, + "spool": { + "delivery_id": "5b415e75-b92e-4f52-bd8d-57d4f48c43c2", + "state": "submitted", + "queued_submission_id": "01a0e91e-92e3-7840-9f27-27d0bd61ee74", + "accepted_at": "2026-09-28T17:44:50.148Z", + "reason": "codex_queue_accepted" + }, + "receipt": { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "codex_queue_accepted", + "receipt_revision": 1, + "queued_submission_id": "01a0e91e-92e3-7840-9f27-27d0bd61ee74", + "accepted_at": "2026-09-28T17:44:50.148Z" + } + }, + { + "status": "passed", + "reasons": [], + "label": "all", + "recipient": "bell", + "original": { + "room": "parent-acceptance-codex-ea9a66d0-7563-4781-9be6-31a13d843833", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_ALL_6dd306ae-fe9c-4bfa-acfb-307d5d9135cc です。" + }, + "received": { + "room": "parent-acceptance-codex-ea9a66d0-7563-4781-9be6-31a13d843833", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_ALL_6dd306ae-fe9c-4bfa-acfb-307d5d9135cc です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-codex-ea9a66d0-7563-4781-9be6-31a13d843833", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_ALL_6dd306ae-fe9c-4bfa-acfb-307d5d9135cc です。", + "delivery_id": "734cd46a-e2a2-482b-a664-90063889eedc", + "digest": "15cf2b2fd558aa2951bc7e7a0a2b01325752c126d91ee2ca7ecd19110c5fbdd2", + "preview": false, + "order": 56, + "session": "01a0e91e-0c5f-75e1-b14b-30423f8856c9", + "turn_id": "01a0e91e-ca1f-7ac1-8d1e-cb097fd085f9", + "hook": null, + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "entry": { + "uuid": null, + "type": "response_item:message:user", + "role": "user" + } + }, + "reply": { + "order": 59, + "session": "01a0e91e-0c5f-75e1-b14b-30423f8856c9", + "turn_id": "01a0e91e-ca1f-7ac1-8d1e-cb097fd085f9", + "text": "- 符号:`PEERTABLE_ACCEPT_CODEX_ALL_6dd306ae-fe9c-4bfa-acfb-307d5d9135cc`\n- 送信者:`probe`\n- 宛先:`all`\n- seq:`5`" + }, + "spool": { + "delivery_id": "734cd46a-e2a2-482b-a664-90063889eedc", + "state": "submitted", + "queued_submission_id": "01a0e91e-ba15-7c60-8ff1-e87112edb0e3", + "accepted_at": "2026-09-28T17:45:00.182Z", + "reason": "codex_queue_accepted" + }, + "receipt": { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "codex_queue_accepted", + "receipt_revision": 1, + "queued_submission_id": "01a0e91e-ba15-7c60-8ff1-e87112edb0e3", + "accepted_at": "2026-09-28T17:45:00.182Z" + } + } + ], + "transcript_rows": 63, + "turns_seen": [ + "01a0e91e-211e-7933-8df9-97100f5a4276", + "01a0e91e-a2f7-7211-97a5-54b08d50d383", + "01a0e91e-ca1f-7ac1-8d1e-cb097fd085f9" + ] + }, + "run_status": "passed", + "errors": [], + "findings": [], + "cleanup": [ + { + "label": "harness_exit", + "status": "done", + "pid": 70793, + "exited": true + }, + { + "label": "endpoint_stop", + "status": "done", + "product_index_left_after_stop": false, + "runtime_before": "stopped", + "runtime_after": "stopped", + "product_stopped_within_30s": true, + "stopped_by_runner": false, + "owned_alive_after": [] + }, + { + "label": "codex_folder_trust_remove", + "status": "done", + "removed_trust_entries": 1, + "remaining_trust_entries": 0 + }, + { + "label": "pty_close", + "status": "done", + "session": "ptacc-codex-ece79afe", + "outcome": "already_closed", + "pane_pid": 70793, + "pane_alive_after": false + }, + { + "label": "room_server", + "status": "done", + "pid": 70735, + "exit": "SIGTERM", + "alive_after": false + }, + { + "label": "connect_remove", + "status": "done", + "removed": [ + "codex:removed" + ], + "semantic_equal": true, + "effective_diff_keys": [], + "hooks_diff_keys": [], + "config_text_equal": true, + "backup": null + } + ] + } + ] +} From ee41fa2d5a3d67b66f63768ca7c0a7eb98d4b546 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:09:56 +0900 Subject: [PATCH 22/72] =?UTF-8?q?Jev=E3=81=AB=E3=82=88=E3=82=8B=E7=94=BB?= =?UTF-8?q?=E9=9D=A2=E5=8F=96=E5=BE=97=E3=81=AE=E5=86=8D=E7=A2=BA=E8=AA=8D?= =?UTF-8?q?=E7=B5=90=E6=9E=9C=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rag/parent-delivery/gui-environment-preparation.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/rag/parent-delivery/gui-environment-preparation.md b/rag/parent-delivery/gui-environment-preparation.md index 08435506..21131bc9 100644 --- a/rag/parent-delivery/gui-environment-preparation.md +++ b/rag/parent-delivery/gui-environment-preparation.md @@ -1,5 +1,7 @@ # GUI実機試験の環境準備 +出典: 対象hostの公式package、process、上流Jev実行入口の実測。取得日: 2026-09-29。確度: 記載した導入と観測結果は確認済み。配送の成立は未確認。 + GUIの配送試験は未合格である。この記録は公式アプリと拡張の準備を示し、親へのnative配送の証拠には数えない。 LinuxとWindowsのCodex Appは正式なpackageと稼働processを確認した。Linuxはseat0のactiveなWayland session、Windowsはactiveなconsole sessionを確認している。SSHでDISPLAYが空でもGUI sessionの不存在とは判定しない。認証情報・個人の会話は読んでいない。 @@ -7,3 +9,7 @@ LinuxとWindowsのCodex Appは正式なpackageと稼働processを確認した。 macOSのVS CodeとCodex拡張は導入済みである。不足していたLinuxのVS Code/Cursor Desktop、WindowsのVS Codeと両OSのCodex拡張を導入した。Linuxは[VS Code公式手順](https://code.visualstudio.com/docs/setup/linux)と[Cursor公式手順](https://cursor.com/docs/get-started/quickstart)のapt repositoryと署名keyを使い、WindowsはwingetのMicrosoft.VisualStudioCode user installerを使った。拡張は[VS Code公式CLI](https://code.visualstudio.com/docs/configure/command-line)のinstall-extensionでOpenAIのopenai.chatgptを導入した。拡張の用途は[OpenAI公式IDE案内](https://learn.chatgpt.com/docs/codex/ide)を参照する。 導入処理は既存のCodex設定・Peertableの接続・他のhookを編集していない。今回の導入ではGUIアプリを起動していない。残る観測は、対象アプリと拡張のサインイン、専用projectの実会話、背景配送後の同じ会話の継続と原文確認である。Jevの画面取得が停止しているため、GUI試験に限る操作ツール変更についてオーナーの回答を待っている。 + +macOSで、上流の`scripts/jev/run.mjs`へ「画面取得の確認だけを行い、操作しない」と渡して再確認した。Cursorは`AMBIGUOUS_TARGET`で画面を取得できなかった。Codexの表示名を指定した初回は`APP_NOT_FOUND`となり、公式`list-apps`のアプリ識別子を照合した。実processは表示名`ChatGPT`、bundle ID`com.openai.codex`であり、その正確なbundle IDを指定した再確認は`ACTION_NOT_SUPPORTED`だった。初回の名前不一致をアプリの不存在とは扱わない。 + +いずれも画面取得前に終了し、Jevへの画面送信・選択判断・GUI操作に進んでいない。新しいウィンドウや試験チャットを作らず、メッセージも送っていない。これらの結果から画面操作の可用性を成功とは判定せず、既存画面を閉じたり、代替ツールへ切り替えたりしない。 From 43a76265dda1a41cc1ea4b722426a9ffd0e3c783 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:16:03 +0900 Subject: [PATCH 23/72] =?UTF-8?q?Aiterm=E5=85=AC=E9=96=8B=E7=89=88?= =?UTF-8?q?=E3=81=A8=E8=A6=AA=E8=BF=94=E4=BF=A1=E3=81=AE=E5=A5=91=E7=B4=84?= =?UTF-8?q?=E3=82=92=E5=86=8D=E7=85=A7=E5=90=88=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rag/parent-delivery/receivers.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rag/parent-delivery/receivers.md b/rag/parent-delivery/receivers.md index 51510107..d1f45e4e 100644 --- a/rag/parent-delivery/receivers.md +++ b/rag/parent-delivery/receivers.md @@ -1,6 +1,6 @@ # 親セッションの公式受信口 -取得日: 2026-09-28。確度: Aitermのsource確認、公式仕様と配布資料の確認、固定snapshotのCLI実測。Peertable全対応の製品受入は未達。 +取得日: 2026-09-29。確度: Aitermのsource確認、公式仕様と配布資料の確認、固定snapshotのCLI実測。Peertable全対応の製品受入は未達。 この文書は調査記録である。実装要求は[配送設計](../../docs/plan_parent-native-delivery.md)を正本とする。Aitermの内部state・hook・moduleは参照資料だけであり、Peertableの実行時依存にしない。 @@ -8,6 +8,10 @@ 比較commitは`8cfe1da43a28606ccb53acd1dbfcc0607436add0`。以下は同commitのsourceを読んで確認した。 +公開snapshotの再照合では、npmの`aiterm-mcp`が0.42.2、公開commitが[`9673442989b375bbdd7563f9c4b509924ae0b0b7`](https://github.com/kitepon/aiterm-mcp/commit/9673442989b375bbdd7563f9c4b509924ae0b0b7)であることを確認した。基準とのGit差分を読み、親配送manager、Codex/Claude/Cursorのreceiver・hook、Codex hook stateと導入、背景waitの12ファイルはGit blob IDがすべて一致した。requestでの親識別とreceiver選択にも変更はない。 + +同snapshotの変更はClaude/Codexの利用上限の観測、完了記録を優先したCursorのSteer/新turn判定、BellTeam向けの途中経過metadataに関するものである。前二つはAitermが所有する子の完了観測・通常席の送信判断であり、Peertableは公開APIを通して使う。途中経過metadataは親返信の本文receiverの契約とは別である。Peertableの親配送機構の変更を要する差分は見つからず、検証中の製品sourceを変更しない。 + - [requestでの親識別と背景受信](https://github.com/kitepon/aiterm-mcp/blob/8cfe1da43a28606ccb53acd1dbfcc0607436add0/src/index.ts): Codex、Claude Code、Cursorのreceiverを選ぶ。これ以外の親は構造化`wait_process`で子の完了を受信して結果を回収する。 - [配送の保存と復旧](https://github.com/kitepon/aiterm-mcp/blob/8cfe1da43a28606ccb53acd1dbfcc0607436add0/src/parent-delivery.ts): waiting/ready/sending/submitted/failed/unknownを保存。孤立したsendingはunknownとし再送しない。子の回答保存と親への受信は別処理。 - [Codex receiver](https://github.com/kitepon/aiterm-mcp/blob/8cfe1da43a28606ccb53acd1dbfcc0607436add0/src/codex-parent-receiver.ts)、[同期hook](https://github.com/kitepon/aiterm-mcp/blob/8cfe1da43a28606ccb53acd1dbfcc0607436add0/src/codex-parent-hooks.ts): 公式queueへ投入し、所有記録に一致した項目だけを同じturnへ渡す。 From 6808909b51775b3f0861131a83dd645f3b7b49ce Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:19:43 +0900 Subject: [PATCH 24/72] =?UTF-8?q?=E8=A6=AA=E9=85=8D=E9=80=81=E3=81=AE?= =?UTF-8?q?=E6=AD=A3=E5=B8=B8=E5=8F=97=E4=BF=A1=E3=81=A8=E4=BC=9A=E8=A9=B1?= =?UTF-8?q?=E5=88=A5=E8=A6=B3=E6=B8=AC=E3=82=92=E5=8E=B3=E5=AF=86=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scenarios-context.mjs | 178 +++++++++++++----- .../parent-product-acceptance/scenarios.mjs | 2 +- .../scenarios.test.mjs | 50 ++++- 3 files changed, 184 insertions(+), 46 deletions(-) diff --git a/experiments/parent-product-acceptance/scenarios-context.mjs b/experiments/parent-product-acceptance/scenarios-context.mjs index 7e543c04..ffd56618 100644 --- a/experiments/parent-product-acceptance/scenarios-context.mjs +++ b/experiments/parent-product-acceptance/scenarios-context.mjs @@ -5,7 +5,7 @@ import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { randomUUID } from 'node:crypto' import { judgeAudience, sha256 } from './evidence.mjs' -import { readTranscript, transcriptPath } from './harness.mjs' +import { isDeepStrictEqual } from 'node:util' import { scenarioPlan, scenarioNames } from './scenarios.mjs' const fail = (code, detail) => { throw Object.assign(new Error(detail), { code }) } @@ -89,62 +89,146 @@ export function assembleNativePages(pages) { return pages.at(-1).page.complete && offset === first.total_characters ? { body, first, final: pages.at(-1) } : null } +// 判定は実会話・room receipt・原文保存を直接使い、未収集のscope.checksに依存しない。 +export function noToolsReception({ posted, record, receipt, seen, session, runtime }) { + if (record?.event?.body !== posted.body) fail('ACCEPTANCE_NO_TOOLS_BODY_LOST', '全toolなしの原文を製品が保持していません') + const delivery = seen.deliveries.find(item => item.delivery_id === record.delivery_id && item.session === session && item.body === posted.body) + const reply = delivery && seen.replies.find(item => item.session === session && item.turn_id && item.order > delivery.order && item.text.includes(posted.nonce)) + if (record.state === 'submitted' && receipt?.state === 'delivered' && delivery?.turn_id && reply) return { state: 'native_received', delivery, reply } + if (runtime === 'rearm_pending' && ['ready', 'sending'].includes(record.state) && receipt?.state !== 'delivered') return { state: 'rearm_pending', delivery: null, reply: null } + fail('ACCEPTANCE_NO_TOOLS_STATE_UNEXPLAINED', `実返答/receipt/原文保持を説明できません: ${runtime}/${record.state}/${receipt?.state ?? 'missing'}`) +} + +export function codexTurns(file) { + const turns = new Map(); let current = null + for (const [order, line] of readFileSync(file, 'utf8').split('\n').slice(0, -1).entries()) { + const row = JSON.parse(line), value = row.payload ?? {} + if (row.type === 'turn_context') current = value.turn_id ?? current + if (row.type !== 'event_msg') continue + const id = value.turn_id ?? current + if (!id) continue + if (['task_started', 'turn_started'].includes(value.type)) { current = id; turns.set(id, { turn_id: id, started_order: order, completed_order: null }) } + if (['task_complete', 'turn_complete', 'task_interrupted'].includes(value.type)) { + const saved = turns.get(id) ?? { turn_id: id, started_order: null } + saved.completed_order = order; saved.end_kind = value.type; turns.set(id, saved) + } + } + return turns +} + +export function assertCodexBusy({ workTurn, delivery, record, turns }) { + const native = turns.get(workTurn) + if (!workTurn || delivery.turn_id !== workTurn || !['postToolUse', 'post_tool_use', 'post-tool-use', 'PostToolUse', 'stop', 'Stop'].includes(delivery.hook) || record.hook_turn !== workTurn || record.hook_state !== 'output_complete' || !native || native.started_order === null || delivery.order < native.started_order || (native.completed_order !== null && native.completed_order < delivery.order)) fail('ACCEPTANCE_BUSY_CODEX_NATIVE_TURN', '開始した作業と同turnの公式hook配送を確認できません') + return native +} + +// Shellという名前だけでは許可しない。製品が確定した入力と実task/PIDの相関を要求する。 +export function nativeReceiveToolAllowed(use, registrations, session) { + return registrations.some(item => { + const task = item.native_task + let output = use.output ?? use.result + if (typeof output === 'string') { try { output = JSON.parse(output) } catch { return false } } + const taskId = use.native_task_id ?? output?.task_id ?? output?.shell_id + const pid = use.native_task_pid ?? output?.pid + return item.owner_verified === true && item.parent_session === session && (use.session ?? use.parent_session) === session + && use.name === task.input?.name && isDeepStrictEqual(use.input, task.input?.input) + && String(taskId ?? '') === task.id && pid === task.pid + && item.waiter_owner.pid > 0 && item.waiter_owner.started && task.process_identity?.pid === pid && task.process_identity.started + }) +} + export async function createScenarioContext(options) { const { meta, spool, api, observe, submit, file, pkg, projectDir, privateDir, proxy, bin, nativeActions = {}, screen } = options const importInstalled = name => import(pathToFileURL(join(pkg, 'skill/scripts', name)).href) const { PAGE_CHARS } = await importInstalled('parent-delivery.mjs') const { sameProcess, processIdentity, processDescendsFrom, shellCommand } = await importInstalled('parent-platform.mjs') - const recipient = spool.read().name, pending = [], faultState = new Map(), actions = {} - const context = { harness: meta.harness, session: meta.parent_session, pageChars: PAGE_CHARS, actions } + const recipient = spool.read().name, pending = [], faultState = new Map(), actions = {}, nativeRegistrations = [] + const targets = new Map([['self', { meta, spool, api, observe, submit, file, screen }]]) + const context = { harness: meta.harness, session: meta.parent_session, pageChars: PAGE_CHARS, actions, + registerEndpoint(name, target) { + if (!name || !target?.spool || !target.meta?.parent_session || !target.file || typeof target.api !== 'function' || typeof target.observe !== 'function' || typeof target.submit !== 'function') fail('ACCEPTANCE_ENDPOINT_REGISTRATION_INVALID', name) + if (target.meta.harness !== meta.harness || target.meta.source_commit !== meta.source_commit || target.meta.runtime_digest !== meta.runtime_digest || target.meta.package_version !== meta.package_version) fail('ACCEPTANCE_ENDPOINT_SOURCE_MISMATCH', name) + targets.set(name, target); return target + }, endpoint(name = 'self') { const target = targets.get(name); if (!target) fail('ACCEPTANCE_NATIVE_ENDPOINT_ADAPTER_MISSING', name); return target }, + } + const targetFor = posted => posted?.target_object ?? context.endpoint(posted?.target ?? 'self') const anchor = () => { const seen = observe(); return seen.replies.at(-1)?.turn_id ?? seen.toolUses.at(-1)?.turn_id ?? seen.turns.at(-1) } + const captureNativeRegistrations = () => { + for (const target of targets.values()) { + const waiter = target.spool.read().waiter + if (waiter?.native_task?.id && sameProcess(waiter.owner) && !nativeRegistrations.some(item => item.native_task.id === waiter.native_task.id && item.parent_session === target.meta.parent_session)) nativeRegistrations.push({ parent_session: target.meta.parent_session, endpoint_id: target.spool.id, waiter_owner: waiter.owner, native_task: waiter.native_task, owner_verified: true, observed_at: new Date().toISOString() }) + } + } const artifactFor = (scope, label) => { const dir = join(privateDir, 'scenarios', scope.runId, scope.scenario); mkdirSync(dir, { recursive: true, mode: 0o700 }); return join(dir, `${label}.json`) } const result = (scope, expectation, source, detail = {}) => { + captureNativeRegistrations() const artifact = artifactFor(scope, `${scope.step_index ?? randomUUID()}-${expectation}`) const evidence = { kind: expectation, run_id: scope.runId, scenario: scope.scenario, parent_session: meta.parent_session, turn_id: anchor(), source, artifact, ...detail } if (!evidence.turn_id) fail('ACCEPTANCE_NATIVE_TURN_MISSING', expectation) writeFileSync(artifact, JSON.stringify(evidence, null, 2)) return { expectation, verified: true, observations: [evidence] } } - const recordFor = posted => spool.read().records.find(item => item.event.seq === posted.seq) - const receiptFor = async posted => (await api(`deliveries?seq=${posted.seq}`)).delivery[recipient] + const recordFor = posted => targetFor(posted).spool.read().records.find(item => item.event.seq === posted.seq) + const receiptFor = async posted => { const target = targetFor(posted); return (await target.api(`deliveries?seq=${posted.seq}`)).delivery[target.spool.read().name] } const heartbeat = async () => (await api('members')).bridges?.parent_receiver?.endpoints?.find(endpoint => endpoint.endpoint_id === spool.id)?.beat_at const check = async (posted, scope) => { + const target = targetFor(posted), targetMeta = target.meta, targetRecipient = target.spool.read().name await until(`${scope.scenario}:${posted.nonce} 実親の返答`, async () => { + captureNativeRegistrations() const record = recordFor(posted), receipt = await receiptFor(posted) - return record?.state === 'submitted' && receipt?.state === 'delivered' && observe().replies.some(reply => reply.session === meta.parent_session && reply.text.includes(posted.nonce)) + return record?.state === 'submitted' && receipt?.state === 'delivered' && target.observe().replies.some(reply => reply.session === targetMeta.parent_session && reply.text.includes(posted.nonce)) }, options.deliveryTimeout ?? 300000) - const seen = await until('会話末尾確定', () => { const value = observe(); return value.pending_tail === 0 ? value : null }, 30000) + const seen = await until('会話末尾確定', () => { const value = target.observe(); return value.pending_tail === 0 ? value : null }, 30000) const record = recordFor(posted), receipt = await receiptFor(posted) let deliveries = seen.deliveries const preview = deliveries.find(item => item.delivery_id === record.delivery_id && item.preview) - const pages = nativeReadPages(meta.harness, file, record.delivery_id) + const pages = nativeReadPages(targetMeta.harness, target.file, record.delivery_id) if (preview || pages.length) { const assembled = assembleNativePages(pages) - if (!assembled) fail('ACCEPTANCE_NATIVE_WHOLE_BODY_MISSING', `${scope.scenario}: parent_read最終pageがありません`) - // previewと全文pageを別の本文として数えない。実tool出力の最終pageを全文受信の位置にする。 - deliveries = [...deliveries.filter(item => item.delivery_id !== record.delivery_id), { ...(preview ?? {}), room: posted.room, from: assembled.first.event.from ?? assembled.first.event.message?.from, to: assembled.first.event.to_names ?? assembled.first.event.to ?? assembled.first.event.message?.to_names ?? assembled.first.event.message?.to, seq: assembled.first.event.seq, body: assembled.body, delivery_id: record.delivery_id, session: meta.parent_session, turn_id: assembled.final.turn_id, order: assembled.final.order, preview: false, boundary: { encoding: 'none', raw_body_equal: true, native_pages: pages.length } }] + if (!assembled || pages.some(item => item.page.endpoint_id !== target.spool.id)) fail('ACCEPTANCE_NATIVE_WHOLE_BODY_MISSING', `${scope.scenario}: parent_read最終page/endpointが不一致です`) + deliveries = [...deliveries.filter(item => item.delivery_id !== record.delivery_id), { ...(preview ?? {}), room: preview?.room ?? target.spool.read().room, from: assembled.first.event.from ?? assembled.first.event.message?.from, to: assembled.first.event.to_names ?? assembled.first.event.to ?? assembled.first.event.message?.to_names ?? assembled.first.event.message?.to, seq: assembled.first.event.seq, body: assembled.body, delivery_id: record.delivery_id, session: targetMeta.parent_session, turn_id: assembled.final.turn_id, order: assembled.final.order, preview: false, boundary: { encoding: 'none', raw_body_equal: true, native_pages: pages.length } }] } - const judged = judgeAudience({ label: posted.label, posted, record, receipt, deliveries, replies: seen.replies, session: meta.parent_session, recipient }) - copyFileSync(file, artifactFor(scope, `${posted.nonce}-transcript.jsonl`)) - return { ...judged, nonce: posted.nonce, scenario: scope.scenario, run_id: scope.runId, harness: meta.harness } + const judged = judgeAudience({ label: posted.label, posted, record, receipt, deliveries, replies: seen.replies, session: targetMeta.parent_session, recipient: targetRecipient }) + copyFileSync(target.file, artifactFor(scope, `${posted.nonce}-transcript.jsonl`)) + return { ...judged, parent_session: targetMeta.parent_session, endpoint_id: target.spool.id, target: posted.target, nonce: posted.nonce, scenario: scope.scenario, run_id: scope.runId, harness: targetMeta.harness } } const post = async (input, scope) => { - const posted = { ...(await api('messages', { from: 'probe', to: recipient, body: input.body })), room: meta.room, nonce: input.nonce, label: input.label } + const target = context.endpoint(input.endpoint ?? 'self'), targetMeta = target.meta + const posted = { ...(await target.api('messages', { from: 'probe', to: target.spool.read().name, body: input.body })), room: targetMeta.room, nonce: input.nonce, label: input.label, target: input.endpoint ?? 'self', target_object: target } pending.push({ posted, scope: { ...scope }, defer: input.defer, allowRetained: input.allowRetained }) return posted } actions.deliver = async (input, scope) => { - if (input.endpoint) fail('ACCEPTANCE_NATIVE_ENDPOINT_ADAPTER_MISSING', `関連する実親の操作が未接続です: ${input.endpoint}`) - const posted = await post(input, scope) - if (input.defer || input.allowRetained) { - if (!['source_reconnect', 'process_recovery'].includes(scope.scenario)) await until('製品の原文保存', () => recordFor(posted)?.event.body === posted.body, 45000) - const observed = result(scope, 'native_delivery', 'http_boundary', { deferred: true, seq: posted.seq, posted_body_sha256: sha256(posted.body), receipt: await receiptFor(posted) }) - // deferは配送合格ではない。後続pending_recoverの実親本文checkが必須。 - return observed + captureNativeRegistrations() + const posted = await post(input, scope), target = targetFor(posted) + if (input.allowRetained) { + const outcome = await until('全toolなしの受信または明示再武装待ち', async () => { + const record = recordFor(posted), receipt = await receiptFor(posted), seen = target.observe(), runtime = target.spool.read().runtime + if (!record || record.event.body !== posted.body) return null + if (runtime === 'rearm_pending' && ['ready', 'sending'].includes(record.state) && receipt?.state !== 'delivered') return noToolsReception({ posted, record, receipt, seen, session: target.meta.parent_session, runtime }) + if (record.state === 'submitted' && receipt?.state === 'delivered' && seen.replies.some(reply => reply.session === target.meta.parent_session && reply.text.includes(posted.nonce))) return noToolsReception({ posted, record, receipt, seen, session: target.meta.parent_session, runtime }) + return null + }, options.deliveryTimeout ?? 300000) + const value = result(scope, 'native_delivery', outcome.state === 'native_received' ? 'harness_transcript' : 'http_boundary', { no_tools_state: outcome.state, seq: posted.seq, related_session: target.meta.parent_session, related_turn_id: outcome.reply?.turn_id ?? null }) + if (outcome.state === 'native_received') { + const judged = await check(posted, scope); pending.find(item => item.posted === posted).recovered = true + return { ...value, checks: [judged] } + } + return value + } + if (input.defer) { + if (!['source_reconnect', 'process_recovery', 'session_change'].includes(scope.scenario)) await until('製品の原文保存', () => recordFor(posted)?.event.body === posted.body, 45000) + return result(scope, 'native_delivery', 'http_boundary', { deferred: true, seq: posted.seq, related_session: target.meta.parent_session, posted_body_sha256: sha256(posted.body), receipt: await receiptFor(posted) }) } const judged = await check(posted, scope) - return { ...result(scope, 'native_delivery', 'harness_transcript', { seq: posted.seq, delivery_id: judged.delivery?.delivery_id }), check: judged } + if (['busy', 'consecutive'].includes(scope.scenario) && faultState.has(`${scope.runId}:work`) && targetMetaHarness(target) === 'codex') { + const work = faultState.get(`${scope.runId}:work`) + const turn = assertCodexBusy({ workTurn: work?.turn, delivery: judged.delivery, record: recordFor(posted), turns: codexTurns(target.file) }) + faultState.get(`${scope.runId}:work`).arrival_turn_state = turn + } + return { ...result(scope, 'native_delivery', 'harness_transcript', { seq: posted.seq, delivery_id: judged.delivery?.delivery_id, related_session: target.meta.parent_session, related_turn_id: judged.delivery?.turn_id }), parent_session: target.meta.parent_session, check: judged } } + const targetMetaHarness = target => target.meta.harness actions.pending_recover = async (_, scope) => { const selected = pending.filter(item => item.scope.runId === scope.runId && item.scope.scenario === scope.scenario && item.defer && !item.recovered) if (!selected.length) fail('ACCEPTANCE_PENDING_NATIVE_MISSING', scope.scenario) @@ -166,19 +250,24 @@ export async function createScenarioContext(options) { const owner = processIdentity(JSON.parse(readFileSync(identity, 'utf8')).pid) if (!owner || !sameProcess(owner) || !processDescendsFrom(owner, meta.parent_process) || existsSync(completed)) fail('ACCEPTANCE_WORK_NATIVE_OWNER', '実親が所有する存命の作業processを確認できません') const seen = observe() - if (!seen.toolUses.some(use => use.order >= before && (meta.harness === 'claude' ? /Bash/u : /exec_command/u).test(use.name))) fail('ACCEPTANCE_WORK_NATIVE_TOOL', '公式toolの作業開始が実会話にありません') - faultState.set(`${scope.runId}:work`, { owner, identity, release, completed, nonce, before, started_order: seen.rows }) - return result(scope, 'work_running', 'os_process', { owner, work_artifact: identity, start_transcript_rows: before }) + const workUse = seen.toolUses.find(use => use.order >= before && (meta.harness === 'claude' ? /Bash/u : /exec_command/u).test(use.name)) + if (!workUse?.turn_id) fail('ACCEPTANCE_WORK_NATIVE_TOOL', '公式toolの作業開始が実会話にありません') + faultState.set(`${scope.runId}:work`, { owner, identity, release, completed, nonce, before, turn: workUse.turn_id, started_order: workUse.order }) + return result(scope, 'work_running', 'os_process', { owner, work_artifact: identity, start_transcript_rows: before, work_turn_id: workUse.turn_id, work_tool_use_id: workUse.id }) } actions.work_finish = async (_, scope) => { const work = faultState.get(`${scope.runId}:work`), delivery = scope.checks.at(-1) if (!work || !sameProcess(work.owner) || existsSync(work.completed) || !delivery || delivery.delivery.order < work.before) fail('ACCEPTANCE_WORK_NOT_CONTINUED', '配送中に元作業を継続していません') const held = processIdentity(work.owner.pid) + if (meta.harness === 'codex') { + if (!work.arrival_turn_state) fail('ACCEPTANCE_BUSY_CODEX_NATIVE_TURN', '到着時のnative task/turn状態がありません') + assertCodexBusy({ workTurn: work.turn, delivery: delivery.delivery, record: recordFor({ seq: delivery.original.seq }), turns: codexTurns(file) }) + } writeFileSync(work.release, JSON.stringify({ released_at: new Date().toISOString(), delivery_seq: delivery.original.seq })) await until('元作業の完了', () => existsSync(work.completed), 10000) const reply = await until('作業継続の実親応答', () => observe().replies.find(item => item.text.includes(work.nonce) && item.order > delivery.delivery.order), 120000) if (reply.session !== meta.parent_session) fail('ACCEPTANCE_WORK_SESSION_CHANGED', '元作業の完了は別会話です') - return result(scope, 'work_continued', 'harness_transcript', { live_at_delivery: held, completion_artifact: work.completed, work_reply_turn: reply.turn_id, delivery_turn: delivery.delivery.turn_id }) + return result(scope, 'work_continued', 'harness_transcript', { live_at_delivery: held, completion_artifact: work.completed, work_start_turn: work.turn, arrival_turn_state: work.arrival_turn_state ?? null, work_reply_turn: reply.turn_id, delivery_turn: delivery.delivery.turn_id }) } } actions.idle_wait = async (_, scope) => { @@ -191,8 +280,8 @@ export async function createScenarioContext(options) { return prompt.test(view) && current.replies.at(-1)?.order === last.order }, 120000, 1000) // promptが作業中にも表示される実装があるため、native task終了の記録も必要にする。 - const raw = readFileSync(file, 'utf8') - if (meta.harness === 'codex' && !raw.includes('task_complete')) fail('ACCEPTANCE_IDLE_NATIVE_END_MISSING', 'Codexのtask_completeを確認できません') + const ended = meta.harness === 'codex' ? codexTurns(file).get(last.turn_id) : null + if (meta.harness === 'codex' && (!ended || ended.end_kind !== 'task_complete' || ended.completed_order < last.order)) fail('ACCEPTANCE_IDLE_NATIVE_END_MISSING', '対象last turnのtask_completeを確認できません') faultState.set(`${scope.runId}:idle`, { turn: last.turn_id, order: last.order, rows: seen.rows }) return result(scope, 'idle_without_input', 'harness_transcript', { idle_turn: last.turn_id, reply_order: last.order }) } @@ -216,21 +305,22 @@ export async function createScenarioContext(options) { actions.tools_observe = async (_, scope) => { const mode = scope.scenario === 'no_tools' ? 'none' : 'parent_only', before = faultState.get(`${scope.runId}:policy:${mode}`) const used = observe().toolUses.filter(item => item.order > before.order && item.name !== 'output') - if (mode === 'none' ? used.length : used.some(item => !/parent_(?:join|read|leave)/u.test(item.name))) fail('ACCEPTANCE_TOOL_POLICY_VIOLATED', JSON.stringify(used.map(item => item.name))) - if (mode === 'none') { - const saved = spool.read(), sent = pending.filter(item => item.scope.runId === scope.runId) - if (!sent.every(item => recordFor(item.posted)?.event.body === item.posted.body)) fail('ACCEPTANCE_NO_TOOLS_BODY_LOST', scope.scenario) - if (!scope.checks.length && saved.runtime !== 'rearm_pending') fail('ACCEPTANCE_NO_TOOLS_STATE_UNEXPLAINED', saved.runtime) - } - return result(scope, mode === 'none' ? 'no_tools_or_explicit_rearm' : 'no_external_tools', 'harness_transcript', { tools: used, runtime: spool.read().runtime }) + if (mode === 'none' ? used.length : used.some(item => !/parent_(?:join|read|leave)/u.test(item.name) && !nativeReceiveToolAllowed(item, nativeRegistrations, meta.parent_session))) fail('ACCEPTANCE_TOOL_POLICY_VIOLATED', JSON.stringify(used.map(item => item.name))) + const noTools = mode === 'none' ? await Promise.all(pending.filter(item => item.scope.runId === scope.runId && item.allowRetained).map(async item => { const target = targetFor(item.posted); return noToolsReception({ posted: item.posted, record: recordFor(item.posted), receipt: await receiptFor(item.posted), seen: target.observe(), session: target.meta.parent_session, runtime: target.spool.read().runtime }) })) : [] + return result(scope, mode === 'none' ? 'no_tools_or_explicit_rearm' : 'no_external_tools', 'harness_transcript', { tools: used, native_registrations: nativeRegistrations, runtime: spool.read().runtime, no_tools_states: noTools.map(item => item.state) }) } actions.retained_recover = async (_, scope) => { - const selected = pending.filter(item => item.scope.runId === scope.runId && item.allowRetained) - // no_toolsでtoolなしのnative受信が成立した場合だけ、owner_input_required:falseの実績を作る。 - // 再武装が必要な時はその事実を失敗として報告し、後続のuser指示で起こして合格に変えない。 - for (const item of selected) if (!observe().replies.some(reply => reply.text.includes(item.posted.nonce))) fail('ACCEPTANCE_NO_TOOLS_EXPLICIT_REARM_PENDING', '原文は保持されましたが全toolなしでnative起床できていません') - const checks = await Promise.all(selected.map(item => check(item.posted, scope))) - return { ...result(scope, 'retained_native_recovery', 'harness_transcript'), checks } + const selected = pending.filter(item => item.scope.runId === scope.runId && item.allowRetained && !item.recovered) + const checks = [] + for (const item of selected) { checks.push(await check(item.posted, scope)); item.recovered = true } + return { ...result(scope, 'retained_native_recovery', 'harness_transcript', { already_received: selected.length === 0 }), checks } + } + actions.receiving_observe = async (_, scope) => { + if (scope.checks.length < 2 || scope.checks.at(-1).reply.turn_id === scope.checks[0].reply.turn_id) fail('ACCEPTANCE_RECEIVING_NOT_MAINTAINED', '2通目のidle起床がありません') + const current = spool.read() + if (['cursor', 'grok'].includes(meta.harness)) await until('2通目後のnative受信再武装', () => { const state = spool.read(); return state.runtime === 'armed' && state.waiter?.native_task?.id && sameProcess(state.waiter.owner) }, 120000) + else if (!sameProcess(current.watcher) || !['armed', 'rearm_pending'].includes(current.runtime)) fail('ACCEPTANCE_RECEIVING_NOT_MAINTAINED', current.runtime) + return result(scope, 'native_receiving_maintained', 'harness_transcript', { reply_turns: scope.checks.map(item => item.reply.turn_id), runtime: spool.read().runtime, waiter: spool.read().waiter, watcher: spool.read().watcher }) } actions.pages_observe = async (_, scope) => { if (!scope.checks.some(item => item.original.body.length > PAGE_CHARS) || scope.checks.some(item => item.body_equal !== true)) fail('ACCEPTANCE_WHOLE_BODY_MISSING', scope.scenario) @@ -419,7 +509,7 @@ export async function createScenarioContext(options) { // callbackが無い境界はrunScenarioが事前にtyped errorで停止する。 for (const [name, action] of Object.entries(nativeActions)) { if (actions[name]) fail('ACCEPTANCE_ACTION_OVERRIDE', `既存の実操作を上書きできません: ${name}`) - actions[name] = (input, scope) => action(input, { ...scope, meta, spool, api, observe, submit, file, pkg, projectDir, privateDir, result, pending, check, post, faultState, sameProcess, processIdentity, processDescendsFrom, shellCommand }) + actions[name] = (input, scope) => action(input, { ...scope, meta, spool, api, observe, submit, file, pkg, projectDir, privateDir, result, pending, check, post, faultState, context, targetFor, recordFor, receiptFor, heartbeat, artifactFor, until, sameProcess, processIdentity, processDescendsFrom, shellCommand }) } context.finalize = async scope => { const monitor = faultState.get(`${scope.runId}:interrupt`) @@ -430,7 +520,7 @@ export async function createScenarioContext(options) { writeFileSync(work.release, JSON.stringify({ released_at: new Date().toISOString(), cleanup: true })) if (work.owner && sameProcess(work.owner)) { try { await until('試験作業processの後片付け', () => !sameProcess(work.owner), 10000, 100) } - catch (error) { if (error.code !== 'ACCEPTANCE_TIMEOUT') throw error; if (sameProcess(work.owner)) process.kill(work.owner.pid, 'SIGKILL') } + catch (error) { if (error.code !== 'ACCEPTANCE_TIMEOUT') throw error; if (sameProcess(work.owner)) process.kill(work.owner.pid, 'SIGKILL'); await until('強制終了後の試験process消失', () => !sameProcess(work.owner), 10000, 100) } } } } diff --git a/experiments/parent-product-acceptance/scenarios.mjs b/experiments/parent-product-acceptance/scenarios.mjs index b6aa2ee4..1f96ebfd 100644 --- a/experiments/parent-product-acceptance/scenarios.mjs +++ b/experiments/parent-product-acceptance/scenarios.mjs @@ -24,7 +24,7 @@ export function scenarioPlan(name, { pageChars } = {}) { final_race: [native('final_arm', 'final_boundary_armed'), send('終了境界'), native('final_observe', 'final_no_loss_duplicate')], idle: [native('idle_wait', 'idle_without_input'), send('待機から起床'), native('idle_observe', 'idle_new_turn_without_input')], consecutive: [send('1通目'), native('idle_wait', 'idle_without_input'), send('2通目'), native('work_start', 'work_running'), send('3通目'), native('work_finish', 'work_continued'), native('sequence_observe', 'seq_order')], - no_external_tools: [native('policy_set', 'policy_installed', { tools: 'parent_only' }), send('外部作業toolなし'), native('tools_observe', 'no_external_tools')], + no_external_tools: [native('policy_set', 'policy_installed', { tools: 'parent_only' }), send('外部作業toolなし1通目'), native('idle_wait', 'idle_without_input'), send('外部作業toolなし2通目'), native('idle_observe', 'idle_new_turn_without_input'), native('tools_observe', 'no_external_tools'), native('receiving_observe', 'native_receiving_maintained')], no_tools: [native('policy_set', 'policy_installed', { tools: 'none' }), send('全toolなし', { allowRetained: true }), native('tools_observe', 'no_tools_or_explicit_rearm'), native('policy_set', 'policy_installed', { tools: 'normal' }), native('retained_recover', 'retained_native_recovery')], original: [send('日本語・引用・改行', { body: `日本語😀\n「引用」 'q' "double" & 字面>\n\\path\\改行\r\n末尾\n` }), send('長文全量', { long: true }), native('pages_observe', 'whole_body_recovered')], burst: [native('burst_start', 'burst_above_page_limit', { count: 4 }), native('burst_observe', 'unread_until_last_page'), native('burst_finish', 'burst_seq_whole_body')], diff --git a/experiments/parent-product-acceptance/scenarios.test.mjs b/experiments/parent-product-acceptance/scenarios.test.mjs index 44c685d2..90eb220d 100644 --- a/experiments/parent-product-acceptance/scenarios.test.mjs +++ b/experiments/parent-product-acceptance/scenarios.test.mjs @@ -7,7 +7,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { scenarios as inventory } from '../../scripts/parent-delivery-acceptance.mjs' import { scenarioNames, scenarioPlan, runScenario, validateBoundary, validateNativeCheck } from './scenarios.mjs' -import { createScenarioProxy, nativeReadPages, assembleNativePages } from './scenarios-context.mjs' +import { createScenarioProxy, nativeReadPages, assembleNativePages, noToolsReception, codexTurns, assertCodexBusy, nativeReceiveToolAllowed } from './scenarios-context.mjs' const check = () => ({ run_id: 'run', scenario: 'package', status: 'passed', body_equal: true, count: 1, nonce: 'unique', harness: 'claude', original: { room: 'room', from: 'probe', to: 'bell', seq: 1, body: '日本語 & 字面>' }, received: { room: 'room', from: 'probe', to: 'bell', seq: 1, body: '日本語 & 字面>' }, delivery: { session: 'session', turn_id: 'turn', order: 2 }, reply: { session: 'session', turn_id: 'turn2', order: 3, text: 'unique' }, receipt: { result: 'delivered', receipt_revision: 1 } }) const checkScope = { runId: 'run', scenario: 'package', session: 'session' } @@ -92,3 +92,51 @@ test('実行途中のfailureでも専用processのcleanupを呼ぶ', async () => await assert.rejects(runScenario('package', { harness: 'claude', session: 'session', runId: 'run', pageChars: 100, actions, finalize: async scope => { finalized = scope } }), { code: 'FIXTURE_FAILED' }) assert.equal(finalized.scenario, 'package'); assert.equal(finalized.runId, 'run') }) + +test('no_tools: scopeの未収集checksと無関係に正常なClaude/Codex受信を認める', () => { + for (const harness of ['claude', 'codex']) { + const value = noToolsReception({ posted: { body: '日本語', nonce: '符号' }, record: { delivery_id: 'id', state: 'submitted', event: { body: '日本語' } }, receipt: { state: 'delivered' }, seen: { deliveries: [{ delivery_id: 'id', session: 's', turn_id: 't', body: '日本語', order: 1 }], replies: [{ session: 's', turn_id: 't2', order: 2, text: '符号' }] }, session: 's', runtime: 'armed', harness }) + assert.equal(value.state, 'native_received') + } +}) +test('no_tools: 原文を保った明示rearm_pendingを正常受信と区別する', () => { + const value = noToolsReception({ posted: { body: '日本語', nonce: '符号' }, record: { delivery_id: 'id', state: 'ready', event: { body: '日本語' } }, receipt: null, seen: { deliveries: [], replies: [] }, session: 's', runtime: 'rearm_pending' }) + assert.equal(value.state, 'rearm_pending'); assert.equal(value.reply, null) + assert.throws(() => noToolsReception({ posted: { body: '原文' }, record: { event: { body: '変更' } }, receipt: null, seen: { deliveries: [], replies: [] }, session: 's', runtime: 'rearm_pending' }), { code: 'ACCEPTANCE_NO_TOOLS_BODY_LOST' }) +}) +test('no_external_toolsは2通とidle新turnと次受信の維持を要求する', () => { + const plan = scenarioPlan('no_external_tools') + assert.equal(plan.filter(item => item.action === 'deliver').length, 2) + assert.ok(plan.some(item => item.action === 'idle_wait')) + assert.ok(plan.some(item => item.action === 'idle_observe')) + assert.equal(plan.at(-1).expectation, 'native_receiving_maintained') +}) +test('Codex busyは同turn・公式hook・到着時の実task状態を要求する', () => { + const value = { workTurn: 'busy', delivery: { turn_id: 'busy', hook: 'post_tool_use', order: 3 }, record: { hook_turn: 'busy', hook_state: 'output_complete' }, turns: new Map([['busy', { started_order: 1, completed_order: 5 }]]) } + assert.equal(assertCodexBusy(value).started_order, 1) + assert.throws(() => assertCodexBusy({ ...value, delivery: { ...value.delivery, turn_id: 'idle-next' } }), { code: 'ACCEPTANCE_BUSY_CODEX_NATIVE_TURN' }) + assert.throws(() => assertCodexBusy({ ...value, delivery: { ...value.delivery, hook: null } }), { code: 'ACCEPTANCE_BUSY_CODEX_NATIVE_TURN' }) + assert.throws(() => assertCodexBusy({ ...value, turns: new Map([['busy', { started_order: 1, completed_order: 2 }]]) }), { code: 'ACCEPTANCE_BUSY_CODEX_NATIVE_TURN' }) +}) +test('Codex idleは過去のtask_completeを別turnへ流用しない', t => { + const dir = mkdtempSync(join(tmpdir(), 'peertable-scenarios-turn-')); t.after(() => rmSync(dir, { recursive: true, force: true })) + const file = join(dir, 'fixture.jsonl') + writeFileSync(file, [ + { type: 'event_msg', payload: { type: 'task_started', turn_id: 'old' } }, + { type: 'event_msg', payload: { type: 'task_complete', turn_id: 'old' } }, + { type: 'event_msg', payload: { type: 'task_started', turn_id: 'current' } }, + ].map(JSON.stringify).join('\n') + '\n') + const turns = codexTurns(file) + assert.equal(turns.get('old').completed_order, 1); assert.equal(turns.get('current').completed_order, null) +}) + +test('受信維持Shellは完成済み入力・実task ID・PID・所有相関が全て一致した場合だけ許可する', () => { + const registration = { parent_session: 's', owner_verified: true, waiter_owner: { pid: 10, started: 'waiter-start' }, native_task: { id: 'task', pid: 20, process_identity: { pid: 20, started: 'task-start' }, input: { name: 'Shell', input: { command: 'exact-entry args', cwd: '/fixture' } } } } + const use = { name: 'Shell', session: 's', input: { command: 'exact-entry args', cwd: '/fixture' }, output: { shell_id: 'task', pid: 20 } } + assert.equal(nativeReceiveToolAllowed(use, [registration], 's'), true) + assert.equal(nativeReceiveToolAllowed({ ...use, input: { command: 'unrelated-work', cwd: '/fixture' } }, [registration], 's'), false) + assert.equal(nativeReceiveToolAllowed({ ...use, output: { shell_id: 'other-task', pid: 20 } }, [registration], 's'), false) + assert.equal(nativeReceiveToolAllowed({ ...use, session: 'other-session' }, [registration], 's'), false) + assert.equal(nativeReceiveToolAllowed(use, [{ ...registration, owner_verified: false }], 's'), false) +}) + From 31d0d032f6a7760ccb298a6b2cd224d884977f4d Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:33:45 +0900 Subject: [PATCH 25/72] =?UTF-8?q?=E8=A6=AA=E9=85=8D=E9=80=81=E3=81=AE?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=E3=82=B7=E3=83=8A=E3=83=AA=E3=82=AA=E3=82=92?= =?UTF-8?q?=E6=8E=A5=E7=B6=9A=E3=81=97=E6=AD=A3=E5=BC=8FCLI=E8=A8=BC?= =?UTF-8?q?=E6=8B=A0=E3=82=92=E5=8F=97=E3=81=91=E5=85=A5=E3=82=8C=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plan_parent-native-delivery.md | 4 + .../parent-product-acceptance/README.md | 12 +- .../parent-product-acceptance/evidence.mjs | 2 + .../evidence.test.mjs | 2 + experiments/parent-product-acceptance/run.mjs | 35 +- .../scenarios-context.mjs | 2 +- .../scenarios.test.mjs | 3 +- rag/INDEX.md | 1 + .../cursor-grok-formal-review.json | 96 ++++ .../live/darwin/cursor/cli/audience.json | 526 ++++++++++++++++++ .../live/darwin/grok/cli/audience.json | 497 +++++++++++++++++ .../live/linux/cursor/cli/audience.json | 520 +++++++++++++++++ .../live/linux/grok/cli/audience.json | 497 +++++++++++++++++ .../live/win32/cursor/cli/audience.json | 520 +++++++++++++++++ .../live/win32/grok/cli/audience.json | 497 +++++++++++++++++ rag/parent-delivery/product-acceptance.json | 256 ++++++++- rag/parent-delivery/receivers.md | 4 +- 17 files changed, 3460 insertions(+), 14 deletions(-) create mode 100644 rag/parent-delivery/cursor-grok-formal-review.json create mode 100644 rag/parent-delivery/live/darwin/cursor/cli/audience.json create mode 100644 rag/parent-delivery/live/darwin/grok/cli/audience.json create mode 100644 rag/parent-delivery/live/linux/cursor/cli/audience.json create mode 100644 rag/parent-delivery/live/linux/grok/cli/audience.json create mode 100644 rag/parent-delivery/live/win32/cursor/cli/audience.json create mode 100644 rag/parent-delivery/live/win32/grok/cli/audience.json diff --git a/docs/plan_parent-native-delivery.md b/docs/plan_parent-native-delivery.md index 5f27b2d3..899d94c1 100644 --- a/docs/plan_parent-native-delivery.md +++ b/docs/plan_parent-native-delivery.md @@ -379,3 +379,7 @@ Codexの公式hook本文はXMLのtextとして記録される。[一次仕様の 修理後のmacOS通常CLIで、Claude/CodexのDM・複数宛・ALLについて親の実会話・応答・原文・receiptを再確認し、親終了後の自動停止と共有索引の撤去、他設定の保持を確認した。[修理後の候補観測](../rag/parent-delivery/repaired-cli-candidate-observation.json)は実測commitをそのまま記録し、最終manifestへ流用しない。 最終受入の製品sourceは`43348b8c3557e8abe43e0ee45d3c9b8b19e427f8`へ揃えて測定する。試験制御用の`experiments/`は配布物に含めず、実測した製品sourceとは別にrunnerのcommitを記録する。異なるcommitで得た証拠の`source_commit`を書き換えず、最終対象で実行する。実機で確認した起動dialogと終了確認のrunner修理は統合済みで、関連focused testは9件合格した。 + +この固定sourceでCursor/Grok通常CLIの3 OS audienceを取得し、[親の独立監査](../rag/parent-delivery/cursor-grok-formal-review.json)で18件の原文・公式turn・同会話の後続返答を生証跡と照合して受け入れた。[最終manifest](../rag/parent-delivery/product-acceptance.json)へ実測のまま保存し、他scenarioやDesktopの成功へ広げない。Linux Grokの複数宛とWindows GrokではTUI画面の取得不足を残した。旧候補の証拠を書き換えて使っていない。 + +24 scenarioの手順と実操作moduleをCLI runnerへ接続した。作業中・待機・連続配送・tool省略・原文全量・上限超過・source再接続・出力中断・receipt再送・配布物確認は既存contextで実行でき、残る13 scenarioのnative adapterは実装中である。必要adapterのない試験は操作前にtyped errorで止まり、成績を作らない。統合の関連focused testは25件合格した。製品sourceは固定したまま、試験controllerの各fileのSHA-256を証拠へ記録する。 diff --git a/experiments/parent-product-acceptance/README.md b/experiments/parent-product-acceptance/README.md index a83b7d8f..92556b27 100644 --- a/experiments/parent-product-acceptance/README.md +++ b/experiments/parent-product-acceptance/README.md @@ -7,7 +7,7 @@ ```sh node experiments/parent-product-acceptance/run.mjs --harness claude|codex \ --source <検証対象commit 40桁> --digest --version \ - [--scenarios audience] [--out ] [--model ] + [--scenarios audience,idle,original,...] [--out ] [--model ] ``` runnerは1回の呼出しで次を連続して行う。 @@ -17,7 +17,7 @@ runnerは1回の呼出しで次を連続して行う。 3. 対象harnessのglobal設定をtarで退避し、導入物の`peertable connect --target `でPeertable所有entryだけを登録する。 4. 配布物の`room/server.mjs`で試験専用roomを起動する。tokenは`PEERTABLE_TOKEN_SOURCE_FILE`経由で標準のseat-credential経路へ渡す。 5. Aitermの公開MCP(`aiterm-mcp`)でPTYを開き、通常HOME・通常認証のまま`claude`/`codex`を起動する。Aitermの結果は宣言済みschemaのstructuredContentだけを読み、人間向けtextは解釈しない。`pty_open`は出力schemaを持たないため、指定したnameのsessionを`pty_observe`のstructured結果(`session_id`一致・`exists:true`)で確かめ、確かめられなければ`ACCEPTANCE_AITERM_SESSION_ID_MISSING`で止まる。試験dirの信頼dialogだけを肯定し、未知のdialogでは止まる。 -6. 実親に`parent_join`を1回呼ばせ、`verified`を待ってからscenarioを実測する。 +6. 実親に`parent_join`を1回呼ばせ、`verified`を待ってからscenarioを実測する。製品sourceのcommitと、実行controller各fileのSHA-256は別々に記録する。 7. 後片付けを逆順で行う。harness終了、製品自身のendpoint停止の観測(30秒)、Codexの試験dir信頼entry削除、PTY close、room停止、`connect --remove`、設定の意味比較。各段の結果は`cleanupFailure`が完了条件と照合する。harness未終了、所有processの残存、endpoint未停止、pane・room processの残存、信頼entryの残存、connect解除の失敗、意味比較の不一致、Codex `config.toml`本文の不一致は、それぞれ原因code付きの`failed`になり、runは失敗で終わる。退避tarは`connect_remove`の完了条件をすべて満たした時だけ消す。製品が親終了後30秒以内にendpointを止めなかった場合(runnerが止める)と、停止済みendpointの索引が残った場合(runnerが自分の試験entryだけを外す)は、後片付け自体は続けるが、`findings`へ記録し、`endpoint_stop`を`PRODUCT_ENDPOINT_NOT_SELF_STOPPED`・`PRODUCT_ENDPOINT_STOPPED_BY_RUNNER`・`PRODUCT_STOPPED_ENDPOINT_INDEX_LEFT`の`failed`にしてrunを失敗で終える。 Codexだけ、session層の`-c`を2つ足す。MCPへtoken参照先を渡す`mcp_servers.peertable_parent.env_vars`と、無人実行で更新dialogを出さない`check_for_update_on_startup=false`である。Codexのtrust dialogは`-c`では回避できないため、起動時に受諾する。書かれた`projects.<試験dir>`は、終了後に公式`config/batchWrite`で削除する。 @@ -48,6 +48,10 @@ Codexは`Stop`/`PostToolUse` hookの出力を``要素へ入れ、 ## 実装済みscenario -`audience`(DM・親を含む複数宛・all、各1回、room/from/to/seq/本文一致、同会話の後続返答)だけを実装している。他の24 scenarioを指定すると`ACCEPTANCE_SCENARIO_NOT_IMPLEMENTED`で止まる。代替結果で埋めない。 +`audience`はDM・親を含む複数宛・allを各1回送り、room/from/to/seq/本文一致と同会話の後続返答を測る。他の24 scenarioは`scenarios.mjs`に正本の全手順を持ち、`run.mjs`から`createScenarioContext`/`runScenario`を呼ぶ。contextが実装した操作だけを実行し、必要なnative adapterが揃わないscenarioは障害操作の前に`ACCEPTANCE_SCENARIO_ADAPTER_MISSING`で止まる。手順の存在と実機の合格は別に扱う。 -focused test: `node --test experiments/parent-product-acceptance/evidence.test.mjs` +既存contextで実行できるのは`busy`、`idle`、`consecutive`、`no_external_tools`、`no_tools`、`original`、`burst`、`source_reconnect`、`output_interruption`、`receipt_retry`、`package`。残る13 scenarioのnative adapterは実装中である。各scenarioは専用nonce、実会話・turn・後続返答、原文一致、receipt、境界artifactを照合し、観測が欠ければ成績を作らない。 + +`source_reconnect`と`receipt_retry`では、試験親のHTTP/SSE接続だけを専用proxyへ向ける。runnerの投稿・観測APIはroomへ直接接続する。後片付けではproxyの接続を切ってlistenの終了を確認し、未終了なら`ACCEPTANCE_PROXY_NOT_CLOSED`でrunを失敗にする。 + +focused test: `node --test experiments/parent-product-acceptance/evidence.test.mjs experiments/parent-product-acceptance/scenarios.test.mjs` diff --git a/experiments/parent-product-acceptance/evidence.mjs b/experiments/parent-product-acceptance/evidence.mjs index 359e3c57..c23081ab 100644 --- a/experiments/parent-product-acceptance/evidence.mjs +++ b/experiments/parent-product-acceptance/evidence.mjs @@ -76,6 +76,7 @@ export function buildCase({ meta, scenario, checks, observations, extra = {} }) package_tarball_sha256: meta.package_tarball_sha256, harness_version: meta.harness_version, node_version: meta.node_version, parent_session: meta.parent_session, parent_process: meta.parent_process, endpoint_id: meta.endpoint_id, room: meta.room, owner_input_required: false, isolation: meta.isolation, + ...(meta.controller ? { controller: meta.controller } : {}), observations, receipts: checks.map(check => check.receipt).filter(Boolean), body_checks: checks.filter(check => check.received).map(check => ({ label: check.label, original: check.original, received: check.received })), checks, ...extra, @@ -106,6 +107,7 @@ export function cleanupFailure(label, result) { codex_folder_trust_remove: [[result.remaining_trust_entries !== 0, 'ACCEPTANCE_TRUST_ENTRY_LEFT']], pty_close: [[result.pane_alive_after !== false, 'ACCEPTANCE_PANE_ALIVE'], [!['closed', 'already_closed'].includes(result.outcome), 'ACCEPTANCE_PTY_NOT_CLOSED']], room_server: [[result.alive_after !== false, 'ACCEPTANCE_ROOM_ALIVE']], + scenario_proxy: [[result.closed !== true, 'ACCEPTANCE_PROXY_NOT_CLOSED']], connect_remove: [[!result.removed?.length || result.removed.some(item => !item.endsWith(':removed')), 'ACCEPTANCE_CONNECT_REMOVE_FAILED'], [result.semantic_equal !== true, 'ACCEPTANCE_CONFIG_NOT_RESTORED'], [result.config_text_equal === false, 'ACCEPTANCE_CONFIG_TEXT_CHANGED']], }[label] if (!rules) throw typed('ACCEPTANCE_CLEANUP_UNKNOWN', `完了条件の無い後片付けです: ${label}`) diff --git a/experiments/parent-product-acceptance/evidence.test.mjs b/experiments/parent-product-acceptance/evidence.test.mjs index a2b716b1..84de0735 100644 --- a/experiments/parent-product-acceptance/evidence.test.mjs +++ b/experiments/parent-product-acceptance/evidence.test.mjs @@ -85,6 +85,8 @@ test('後片付けの未完了は成功へ丸めず原因codeを返す', () => { assert.equal(cleanupFailure('endpoint_stop', { owned_alive_after: [], runtime_after: 'stopped' }).code, 'PRODUCT_ENDPOINT_NOT_SELF_STOPPED') assert.equal(cleanupFailure('pty_close', { outcome: 'closed', pane_alive_after: null }).code, 'ACCEPTANCE_PANE_ALIVE') assert.equal(cleanupFailure('room_server', { alive_after: true }).code, 'ACCEPTANCE_ROOM_ALIVE') + assert.equal(cleanupFailure('scenario_proxy', { closed: true }), null) + assert.equal(cleanupFailure('scenario_proxy', { closed: false }).code, 'ACCEPTANCE_PROXY_NOT_CLOSED') assert.equal(cleanupFailure('codex_folder_trust_remove', { remaining_trust_entries: 1 }).code, 'ACCEPTANCE_TRUST_ENTRY_LEFT') assert.equal(cleanupFailure('connect_remove', { removed: ['codex:removed'], semantic_equal: true, config_text_equal: true }), null) assert.equal(cleanupFailure('connect_remove', { removed: ['codex:removed'], semantic_equal: true, config_text_equal: false }).code, 'ACCEPTANCE_CONFIG_TEXT_CHANGED') diff --git a/experiments/parent-product-acceptance/run.mjs b/experiments/parent-product-acceptance/run.mjs index 791bae19..a7967090 100644 --- a/experiments/parent-product-acceptance/run.mjs +++ b/experiments/parent-product-acceptance/run.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node // 親配送の正式受入runner。npm packの配布物をlocal prefixへ導入し、通常CLIの実親で配送を実測する。 -// usage: node run.mjs --harness claude|codex --source <40桁> --digest <64桁> --version [--scenarios audience] [--out ] [--model ] +// usage: node run.mjs --harness claude|codex --source <40桁> --digest <64桁> --version [--scenarios audience,idle,...] [--out ] [--model ] import { chmodSync, rmSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, appendFileSync, copyFileSync, realpathSync } from 'node:fs' import { tmpdir, homedir, platform } from 'node:os' import { dirname, join, resolve } from 'node:path' @@ -12,6 +12,8 @@ import { isDeepStrictEqual } from 'node:util' import { openAiterm } from './aiterm.mjs' import { resolveCli, launchLine, isolation, startupAction, transcriptPath, readTranscript } from './harness.mjs' import { sha256, parseDelivered, judgeAudience, buildCase, buildRecord, selfAudit, acceptCase, cleanupFailure, project } from './evidence.mjs' +import { scenarioNames, runScenario } from './scenarios.mjs' +import { createScenarioContext, createScenarioProxy } from './scenarios-context.mjs' const repo = fileURLToPath(new URL('../../', import.meta.url)) const args = Object.fromEntries(process.argv.slice(2).reduce((pairs, value, index, all) => value.startsWith('--') ? [...pairs, [value.slice(2), all[index + 1]?.startsWith('--') ? true : all[index + 1] ?? true]] : pairs, [])) @@ -20,8 +22,9 @@ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) const osName = platform() const harness = args.harness const scenarios = String(args.scenarios ?? 'audience').split(',') -const known = ['audience'] -for (const name of scenarios) if (!known.includes(name)) fail('ACCEPTANCE_SCENARIO_NOT_IMPLEMENTED', `未実装scenarioは実行も成績作成もしません: ${name}`) +const known = ['audience', ...scenarioNames] +for (const name of scenarios) if (!known.includes(name)) fail('ACCEPTANCE_SCENARIO_UNKNOWN', `正本にないscenarioです: ${name}`) +if (new Set(scenarios).size !== scenarios.length) fail('ACCEPTANCE_SCENARIO_DUPLICATE', '同じscenarioを1回のrunで重複指定できません') if (!['claude', 'codex'].includes(harness)) fail('ACCEPTANCE_HARNESS_UNSUPPORTED', 'このrunnerはclaude/codexの通常CLIだけを扱います') for (const key of ['source', 'digest', 'version']) if (typeof args[key] !== 'string') fail('ACCEPTANCE_ARGS', `--${key} が必要です`) @@ -56,6 +59,9 @@ try { const installedDigest = runtimeDigest() const clientVersion = run(process.execPath, [join(pkg, 'room/client.mjs'), '--version']).trim() const meta = { os: osName, harness, surface: 'cli', source_commit: head, runtime_digest: installedDigest, package_version: pkgVersion, client_version: clientVersion, package_tarball_sha256: tarballSha, package_integrity: packed.integrity, node_version: process.version, isolation: isolation(harness) } + // 固定した製品sourceと、実行した試験controllerの内容は別々に記録する。 + meta.controller = { files_sha256: Object.fromEntries(['run.mjs', 'aiterm.mjs', 'harness.mjs', 'evidence.mjs', 'scenarios.mjs', 'scenarios-context.mjs'] + .map(name => [name, sha256(readFileSync(new URL(name, import.meta.url)))])) } log('package', meta) if (installedDigest !== args.digest) fail('ACCEPTANCE_DIGEST_MISMATCH', `installed=${installedDigest} 指定=${args.digest}`) if (pkgVersion !== args.version || clientVersion !== args.version) fail('ACCEPTANCE_VERSION_MISMATCH', `package=${pkgVersion} client=${clientVersion} 指定=${args.version}`) @@ -96,8 +102,13 @@ try { await api('members', { name: 'probe', harness: 'codex' }) meta.room = room + // 障害を注入するHTTP境界は試験親だけが通る。投稿・観測APIはroomへ直接接続する。 + const proxy = scenarios.some(name => ['source_reconnect', 'receipt_retry'].includes(name)) + ? await createScenarioProxy({ backend: base, artifact: join(privateDir, 'scenario-proxy.jsonl') }) : null + if (proxy) cleanup('scenario_proxy', () => proxy.close()) + const projectDir = join(out, `project-${randomUUID()}`); mkdirSync(join(projectDir, '.team'), { recursive: true }) - writeFileSync(join(projectDir, '.team', 'setup-state.json'), JSON.stringify({ room, server_url: base, mode: 'adhoc' })) + writeFileSync(join(projectDir, '.team', 'setup-state.json'), JSON.stringify({ room, server_url: proxy?.url ?? base, mode: 'adhoc' })) const tokenFile = join(privateDir, 'peertable.env'); writeFileSync(tokenFile, `PEERTABLE_POST_TOKEN=${token}\n`, { mode: 0o600 }) // 5. 通常CLIをAiterm公開PTYで起動し、起動dialogへ既定の肯定操作だけを返す。 @@ -215,6 +226,20 @@ try { const audiences = Object.fromEntries(judged.map(item => [item.label, { count: item.count, body_equal: item.body_equal, status: item.status }])) writeCase(evidence, { audiences }) } + const additional = scenarios.filter(name => name !== 'audience') + if (additional.length) { + const context = await createScenarioContext({ meta, spool, api, observe, submit, file, pkg, projectDir, privateDir, proxy, bin, screen: () => aiterm.screen(pty) }) + log('scenario_adapters', { supported: context.supported, missing: context.missing }) + for (const name of additional) { + log('scenario_start', { scenario: name }) + const measured = await runScenario(name, context) + const seen = await until('transcript末尾の確定', () => { const value = observe(); return value.pending_tail === 0 ? value : null }, 30000, 1000) + copyFileSync(file, join(privateDir, `transcript-${meta.parent_session}.jsonl`)) + const evidence = buildCase({ meta, scenario: name, checks: measured.checks, observations: measured.observations, + extra: { run_id: measured.run_id, trace: measured.trace, boundaries: measured.boundaries, transcript_rows: seen.rows, turns_seen: seen.turns } }) + writeCase(evidence) + } + } } catch (error) { if (screenOnError) await screenOnError().catch(capture => summary.errors.push({ code: capture.code ?? 'ACCEPTANCE_SCREEN_CAPTURE_FAILED', message: capture.message })) summary.errors.push({ code: error.code ?? 'ACCEPTANCE_FAILED', message: error.message }) @@ -236,7 +261,7 @@ try { console.log(JSON.stringify({ kind: 'summary', status: summary.status, out, cases: summary.cases.map(item => ({ id: item.id, status: item.status, gate_errors: item.gate_errors })), errors: summary.errors, findings: summary.findings })) } -function writeCase(evidence, extra) { +function writeCase(evidence, extra = {}) { const relative = `rag/parent-delivery/live/${evidence.case_id}.json` const target = join(publicDir, relative); mkdirSync(dirname(target), { recursive: true }) writeFileSync(target, JSON.stringify(evidence, null, 2) + '\n') diff --git a/experiments/parent-product-acceptance/scenarios-context.mjs b/experiments/parent-product-acceptance/scenarios-context.mjs index ffd56618..a2d10610 100644 --- a/experiments/parent-product-acceptance/scenarios-context.mjs +++ b/experiments/parent-product-acceptance/scenarios-context.mjs @@ -47,7 +47,7 @@ export async function createScenarioProxy({ backend, artifact }) { reconnectSource() { sourceBlocked = false; return note('source_restored') }, failReceipts() { receiptBlocked = true; return note('receipt_blocked') }, restoreReceipts() { receiptBlocked = false; return note('receipt_restored') }, - async close() { sourceBlocked = false; receiptBlocked = false; for (const upstream of upstreams) upstream.destroy(); for (const socket of sockets) socket.destroy(); await new Promise(resolve => server.close(resolve)); note('proxy_closed') }, + async close() { sourceBlocked = false; receiptBlocked = false; for (const upstream of upstreams) upstream.destroy(); for (const socket of sockets) socket.destroy(); await new Promise(resolve => server.close(resolve)); note('proxy_closed'); return { closed: !server.listening } }, } } diff --git a/experiments/parent-product-acceptance/scenarios.test.mjs b/experiments/parent-product-acceptance/scenarios.test.mjs index 90eb220d..f33dfea7 100644 --- a/experiments/parent-product-acceptance/scenarios.test.mjs +++ b/experiments/parent-product-acceptance/scenarios.test.mjs @@ -71,7 +71,7 @@ test('fixture proxyで実HTTP source断とreceipt障害を分け、復旧する' const backend = createServer((req, res) => { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ ok: true, path: req.url })) }) await new Promise(resolve => backend.listen(0, '127.0.0.1', resolve)) const proxy = await createScenarioProxy({ backend: `http://127.0.0.1:${backend.address().port}` }) - t.after(async () => { await proxy.close(); await new Promise(resolve => backend.close(resolve)) }) + t.after(async () => { assert.deepEqual(await proxy.close(), { closed: true }); await new Promise(resolve => backend.close(resolve)) }) assert.equal((await fetch(`${proxy.url}/api/room/messages`)).status, 200) proxy.disconnectSource() assert.equal((await fetch(`${proxy.url}/api/room/messages`)).status, 503) @@ -139,4 +139,3 @@ test('受信維持Shellは完成済み入力・実task ID・PID・所有相関 assert.equal(nativeReceiveToolAllowed({ ...use, session: 'other-session' }, [registration], 's'), false) assert.equal(nativeReceiveToolAllowed(use, [{ ...registration, owner_verified: false }], 's'), false) }) - diff --git a/rag/INDEX.md b/rag/INDEX.md index 15f2815d..597ece3c 100644 --- a/rag/INDEX.md +++ b/rag/INDEX.md @@ -4,3 +4,4 @@ - [Codexの公式hook本文の符号化](parent-delivery/codex-hook-body-encoding.md) — 公式XMLの一度の復号と実機本文照合の境界。 - [WindowsのNode終了エラーの観測](parent-delivery/windows-node-exit-observation.json) — CIの内部assertion、上流修理、公式installerによる更新とfocused確認。 - [修理後の通常CLI候補の観測](parent-delivery/repaired-cli-candidate-observation.json) — macOS Claude/Codexの原文・返答・receiptと、親終了後の自動停止・索引撤去・設定復元。 +- [Cursor/Grokの正式CLI証拠の親監査](parent-delivery/cursor-grok-formal-review.json) — 固定した最終製品sourceの6 audience、18本文と公式turn・後続返答の独立照合、画面取得の不足。 diff --git a/rag/parent-delivery/cursor-grok-formal-review.json b/rag/parent-delivery/cursor-grok-formal-review.json new file mode 100644 index 00000000..0fe523cd --- /dev/null +++ b/rag/parent-delivery/cursor-grok-formal-review.json @@ -0,0 +1,96 @@ +{ + "schema": "peertable.parent-evidence-review.v1", + "acquired_at": "2026-09-28T18:31:50.688Z", + "confidence": "実測:親が保存済みのnative task出力・公式会話記録・hook世代・room本文を独立照合", + "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8", + "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", + "package_version": "0.8.63", + "controller_files_sha256": { + "aud.sh": "be6bcf01af4c3a35e9d22f6746cf4f5983bbcd2697e6a634f0bfe56e5c3e317f", + "audf.ps1": "5e717fa3c298ef7bbc7b6fa0647e09005b1f919908d6ec403e4c78cfbd8415c9", + "drive.sh": "ba46912f902790ca542349324578a2faf7bfd07055e1dc948a2970dc0353a871", + "formal-audience.mjs": "713926df496261473508fc89d2f1b4a0f957c0292ef6e24fc6866baf14f330fb", + "lt4.sh": "340b6d4d30c51528f38cf8eedd38983428d3a53870c81a7e6c4d3203e43c7af1", + "one-linux.sh": "6d947a7e93d4a782710b021c2dcdf05f76ee0bd1db2be3802094fdff519c3d8a", + "one.sh": "526f6ed06e226667ac8050bffb1d82ba5720f4d8e48fdb7325b0a0d330a4c85a", + "poll.sh": "0765cd578b1e83a0887cb7ddee4559c556d238c5ae6992f5550b89b84a32e336", + "qa3.mjs": "d23c1a5394148b9f195c5363eae053c51041dad45354a583bc29c9206897c576", + "res.js": "a2e6e5c2a4fcf12a56f1a87a3304378af048de507ee7062068e62754a44201d2", + "run-r2.ps1": "942606f7243c0d8eba614592db1a1651a4bfdfb1e715cc74d01b9a94762ab50a", + "scr.sh": "ec001faee6c89a9547c51b0db52bf38b3f3d3d2a53426ce502a0fb8029864372", + "st.mjs": "736543dfb35ba7f531684d39e641e65bdc7d60d08d69acf9de8a88ddda8d536a", + "w5.ps1": "4361c7b0e1218bf5ba91bd4e28ae078ac965495625e73a41430328f95c52c412", + "wone.ps1": "fc2223550012e4f143b4ac40c50266bba6d8ff0a75e559aeb7e69ad7d9f1c962", + "wst2.ps1": "688deeed2aff7d632502e2b93619ef9db57ea456bb8d3d9ac5d6daac70cd22de", + "wt4.ps1": "60c04053608a185f69f6a806b9ebd6fba9ab3fc58cc9234cf40dfbba4beb1866" + }, + "cases": [ + { + "id": "darwin/cursor/cli/audience", + "raw_body_and_native_turns_verified": 3, + "raw_assistant_replies_verified": 3, + "negative_controls": 2, + "tui_full_body_present": { + "dm": true, + "multiple": true, + "all": true + } + }, + { + "id": "darwin/grok/cli/audience", + "raw_body_and_native_turns_verified": 3, + "raw_assistant_replies_verified": 3, + "negative_controls": 2, + "tui_full_body_present": { + "dm": true, + "multiple": true, + "all": true + } + }, + { + "id": "linux/cursor/cli/audience", + "raw_body_and_native_turns_verified": 3, + "raw_assistant_replies_verified": 3, + "negative_controls": 2, + "tui_full_body_present": { + "dm": true, + "multiple": true, + "all": true + } + }, + { + "id": "linux/grok/cli/audience", + "raw_body_and_native_turns_verified": 3, + "raw_assistant_replies_verified": 3, + "negative_controls": 2, + "tui_full_body_present": { + "dm": true, + "multiple": false, + "all": true + } + }, + { + "id": "win32/cursor/cli/audience", + "raw_body_and_native_turns_verified": 3, + "raw_assistant_replies_verified": 3, + "negative_controls": 2, + "tui_full_body_present": { + "dm": true, + "multiple": true, + "all": true + } + }, + { + "id": "win32/grok/cli/audience", + "raw_body_and_native_turns_verified": 3, + "raw_assistant_replies_verified": 3, + "negative_controls": 2, + "tui_full_body_present": { + "dm": false, + "multiple": false, + "all": false + } + } + ], + "overall_acceptance_complete": false +} diff --git a/rag/parent-delivery/live/darwin/cursor/cli/audience.json b/rag/parent-delivery/live/darwin/cursor/cli/audience.json new file mode 100644 index 00000000..97cdfcf1 --- /dev/null +++ b/rag/parent-delivery/live/darwin/cursor/cli/audience.json @@ -0,0 +1,526 @@ +{ + "schema": "peertable.parent-live-case.v1", + "case_id": "darwin/cursor/cli/audience", + "status": "passed", + "reasons": [], + "native": true, + "os": "darwin", + "harness": "cursor", + "surface": "cli", + "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8", + "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", + "package_version": "0.8.63", + "client_version": "0.8.63", + "package_tarball_sha256": "55f7f8789b24f4a316b9b67414b5e96a63a2422110085215dcb5b485c58e6fc6", + "harness_version": "2026.09.26-dd393fe", + "node_version": "v26.10.0", + "parent_session": "d70e9c86-b865-4732-b6a0-04b6af92f7a5", + "parent_process": { + "pid": 83339, + "parent": 83138, + "started": "Tue Sep 29 02:50:11 2026", + "executable": "~/.local/share/cursor-agent/versions/2026.09.26-dd393fe/node", + "command": "~/.local/bin/cursor-agent --use-system-ca ~/.local/share/cursor-agent/versions/2026.09.26-dd393fe/index.js --model auto --trust --approve-mcps --force" + }, + "endpoint_id": "ff26e7b4-953c-408f-b47f-f87e32ce4daf", + "room": "qa-cg", + "owner_input_required": false, + "isolation": { + "fixture": "dedicated room server + token file, dedicated private QA dir", + "home": "normal HOME/auth (no fake HOME)", + "harness_config": "project .cursor/.grok fixture hooks+MCP (no global connect)", + "install": "npm pack tarball installed with npm install --prefix; product room/client/hook run from the installed package", + "model": "Auto", + "permission": "--force --approve-mcps --trust" + }, + "observations": [ + { + "kind": "parent_join", + "parent_session": "d70e9c86-b865-4732-b6a0-04b6af92f7a5", + "turn_id": "d25ee893-88c5-4ad7-a316-3df60037f7c9", + "tool_use_id": "6b86f959-567b-4647-bcfc-257f6c01d729", + "endpoint_id": "ff26e7b4-953c-408f-b47f-f87e32ce4daf", + "caller_owner": { + "pid": 83339, + "parent": 83138, + "started": "Tue Sep 29 02:50:11 2026", + "executable": "~/.local/share/cursor-agent/versions/2026.09.26-dd393fe/node", + "command": "~/.local/bin/cursor-agent --use-system-ca ~/.local/share/cursor-agent/versions/2026.09.26-dd393fe/index.js --model auto --trust --approve-mcps --force" + }, + "call_generation": "d06778fe-598a-4547-bc2a-890488d386c9" + }, + { + "kind": "native_background_task_armed", + "parent_session": "d70e9c86-b865-4732-b6a0-04b6af92f7a5", + "turn_id": "d25ee893-88c5-4ad7-a316-3df60037f7c9", + "task_id": "910378", + "task_pid": 93197, + "task_process_started": "Tue Sep 29 02:52:57 2026", + "waiter_pid": 93229, + "waiter_started": "Tue Sep 29 02:52:57 2026", + "runtime": "armed", + "task_id_transitions": [ + "POSITIVE A \"seq\":4 task 910375 -> 910376", + "POSITIVE D \"seq\":5 task 910376 -> 910377", + "NEGATIVE X \"seq\":6 (expect no delivery to bell)", + "POSITIVE M \"seq\":7 task 910377 -> 910378", + "NEGATIVE S \"seq\":8 (expect no delivery to bell)", + "STATE 910378" + ] + }, + { + "kind": "delivery_in_parent_conversation", + "label": "dm", + "parent_session": "d70e9c86-b865-4732-b6a0-04b6af92f7a5", + "turn_id": "9570920e-3ac1-4e23-89b7-20fec782b32b", + "seq": 5, + "delivery_id": "77ec67da-355d-4795-bf6a-39d8506947c2", + "entry": { + "type": "native_task_output_file", + "task_id": "910376" + }, + "boundary": { + "source": "cursor native background task output terminals/910376.txt", + "raw_body_equal": true + } + }, + { + "kind": "parent_reply_after_delivery", + "label": "dm", + "parent_session": "d70e9c86-b865-4732-b6a0-04b6af92f7a5", + "turn_id": "9570920e-3ac1-4e23-89b7-20fec782b32b", + "seq": 5, + "reply_excerpt": "受信:\n[Peertable room=qa-cg from=worker to=\"bell\" seq=5]\n本文: QA-D-MCUR: 親だけ宛のDMです。符号 DM-MCUR\n[配送ID=77ec67da-355d-4795-bf6a-39d8506947c2 digest=f1442b218bb4cb5c8d3da1a3ac0b79082188b343cb38cd57b00ecd94cbd" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "multiple", + "parent_session": "d70e9c86-b865-4732-b6a0-04b6af92f7a5", + "turn_id": "20f73a0b-330f-4eac-8a2f-886c462d5679", + "seq": 7, + "delivery_id": "3cf29106-c9d7-4e37-9631-f3e4d5e901fb", + "entry": { + "type": "native_task_output_file", + "task_id": "910377" + }, + "boundary": { + "source": "cursor native background task output terminals/910377.txt", + "raw_body_equal": true + } + }, + { + "kind": "parent_reply_after_delivery", + "label": "multiple", + "parent_session": "d70e9c86-b865-4732-b6a0-04b6af92f7a5", + "turn_id": "20f73a0b-330f-4eac-8a2f-886c462d5679", + "seq": 7, + "reply_excerpt": "受信:\n[Peertable room=qa-cg from=worker to=[\"bell\",\"peer2\"] seq=7]\n本文: QA-M-MCUR: 親とpeer2宛の複数宛です。符号 MULTI-MCUR\n[配送ID=3cf29106-c9d7-4e37-9631-f3e4d5e901fb digest=93a370f0088a6636a616e48ce50eaf887199aca0d" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "all", + "parent_session": "d70e9c86-b865-4732-b6a0-04b6af92f7a5", + "turn_id": "d25ee893-88c5-4ad7-a316-3df60037f7c9", + "seq": 4, + "delivery_id": "e0799ebb-ddca-41f5-ad7b-cb88f476ad3f", + "entry": { + "type": "native_task_output_file", + "task_id": "910375" + }, + "boundary": { + "source": "cursor native background task output terminals/910375.txt", + "raw_body_equal": true + } + }, + { + "kind": "parent_reply_after_delivery", + "label": "all", + "parent_session": "d70e9c86-b865-4732-b6a0-04b6af92f7a5", + "turn_id": "d25ee893-88c5-4ad7-a316-3df60037f7c9", + "seq": 4, + "reply_excerpt": "受信:\n[Peertable room=qa-cg from=worker to=\"all\" seq=4]\n本文: QA-A-MCUR: 全員宛の確認です。符号 ALL-MCUR\n[配送ID=e0799ebb-ddca-41f5-ad7b-cb88f476ad3f digest=76b094f58485586be3597f63ed6d09ebedc6ff5e46f8bf68dccf2b3aec03" + } + ], + "receipts": [ + { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "cursor_native_background_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + { + "seq": 7, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "cursor_native_background_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "cursor_native_background_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + } + ], + "body_checks": [ + { + "label": "dm", + "original": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-MCUR: 親だけ宛のDMです。符号 DM-MCUR" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-MCUR: 親だけ宛のDMです。符号 DM-MCUR" + } + }, + { + "label": "multiple", + "original": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-MCUR: 親とpeer2宛の複数宛です。符号 MULTI-MCUR" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-MCUR: 親とpeer2宛の複数宛です。符号 MULTI-MCUR" + } + }, + { + "label": "all", + "original": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-MCUR: 全員宛の確認です。符号 ALL-MCUR" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-MCUR: 全員宛の確認です。符号 ALL-MCUR" + } + } + ], + "checks": [ + { + "status": "passed", + "reasons": [], + "label": "dm", + "recipient": "bell", + "original": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-MCUR: 親だけ宛のDMです。符号 DM-MCUR" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-MCUR: 親だけ宛のDMです。符号 DM-MCUR" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-MCUR: 親だけ宛のDMです。符号 DM-MCUR", + "delivery_id": "77ec67da-355d-4795-bf6a-39d8506947c2", + "digest": "f1442b218bb4cb5c8d3da1a3ac0b79082188b343cb38cd57b00ecd94cbd456b3", + "preview": false, + "order": 12, + "session": "d70e9c86-b865-4732-b6a0-04b6af92f7a5", + "turn_id": "9570920e-3ac1-4e23-89b7-20fec782b32b", + "boundary": { + "source": "cursor native background task output terminals/910376.txt", + "raw_body_equal": true + }, + "entry": { + "type": "native_task_output_file", + "task_id": "910376" + } + }, + "reply": { + "session": "d70e9c86-b865-4732-b6a0-04b6af92f7a5", + "text": "受信:\n[Peertable room=qa-cg from=worker to=\"bell\" seq=5]\n本文: QA-D-MCUR: 親だけ宛のDMです。符号 DM-MCUR\n[配送ID=77ec67da-355d-4795-bf6a-39d8506947c2 digest=f1442b218bb4cb5c8d3da1a3ac0b79082188b343cb38cd57b00ecd94cbd456b3]\n\n---\n\nこのDMは待機プロセスが受け取ったもので、`parent_read` で回収したよ。継続tokenは出ていないから、上が全文。通知が来た2件はどちらも前の報告で対応済みで、失敗した方はパスを書き換えてしまった1回目の起動のもの。次の待機プロセスは、返却されたinputを一字も変えずに登録してあるから、このまま次の配送を待つね。", + "order": 15, + "turn_id": "9570920e-3ac1-4e23-89b7-20fec782b32b", + "entry_uuid": null + }, + "spool": { + "delivery_id": "77ec67da-355d-4795-bf6a-39d8506947c2", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "cursor_native_background_output" + }, + "receipt": { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "cursor_native_background_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + "tui_full_body_present": true + }, + { + "status": "passed", + "reasons": [], + "label": "multiple", + "recipient": "bell", + "original": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-MCUR: 親とpeer2宛の複数宛です。符号 MULTI-MCUR" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-MCUR: 親とpeer2宛の複数宛です。符号 MULTI-MCUR" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-MCUR: 親とpeer2宛の複数宛です。符号 MULTI-MCUR", + "delivery_id": "3cf29106-c9d7-4e37-9631-f3e4d5e901fb", + "digest": "93a370f0088a6636a616e48ce50eaf887199aca0d856775dd97ea0a3c24aa48b", + "preview": false, + "order": 20, + "session": "d70e9c86-b865-4732-b6a0-04b6af92f7a5", + "turn_id": "20f73a0b-330f-4eac-8a2f-886c462d5679", + "boundary": { + "source": "cursor native background task output terminals/910377.txt", + "raw_body_equal": true + }, + "entry": { + "type": "native_task_output_file", + "task_id": "910377" + } + }, + "reply": { + "session": "d70e9c86-b865-4732-b6a0-04b6af92f7a5", + "text": "受信:\n[Peertable room=qa-cg from=worker to=[\"bell\",\"peer2\"] seq=7]\n本文: QA-M-MCUR: 親とpeer2宛の複数宛です。符号 MULTI-MCUR\n[配送ID=3cf29106-c9d7-4e37-9631-f3e4d5e901fb digest=93a370f0088a6636a616e48ce50eaf887199aca0d856775dd97ea0a3c24aa48b]\n\n---\n\n`bell` と `peer2` 宛てのメッセージが届いたから、`parent_read` で回収したよ。継続tokenは出ていないから、上が全文。次の待機プロセスは、返却されたinputを一字も変えずに登録してあるから、このまま次の配送を待つね。", + "order": 23, + "turn_id": "20f73a0b-330f-4eac-8a2f-886c462d5679", + "entry_uuid": null + }, + "spool": { + "delivery_id": "3cf29106-c9d7-4e37-9631-f3e4d5e901fb", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "cursor_native_background_output" + }, + "receipt": { + "seq": 7, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "cursor_native_background_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + "tui_full_body_present": true + }, + { + "status": "passed", + "reasons": [], + "label": "all", + "recipient": "bell", + "original": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-MCUR: 全員宛の確認です。符号 ALL-MCUR" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-MCUR: 全員宛の確認です。符号 ALL-MCUR" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-MCUR: 全員宛の確認です。符号 ALL-MCUR", + "delivery_id": "e0799ebb-ddca-41f5-ad7b-cb88f476ad3f", + "digest": "76b094f58485586be3597f63ed6d09ebedc6ff5e46f8bf68dccf2b3aec0378ef", + "preview": false, + "order": 6, + "session": "d70e9c86-b865-4732-b6a0-04b6af92f7a5", + "turn_id": "d25ee893-88c5-4ad7-a316-3df60037f7c9", + "boundary": { + "source": "cursor native background task output terminals/910375.txt", + "raw_body_equal": true + }, + "entry": { + "type": "native_task_output_file", + "task_id": "910375" + } + }, + "reply": { + "session": "d70e9c86-b865-4732-b6a0-04b6af92f7a5", + "text": "受信:\n[Peertable room=qa-cg from=worker to=\"all\" seq=4]\n本文: QA-A-MCUR: 全員宛の確認です。符号 ALL-MCUR\n[配送ID=e0799ebb-ddca-41f5-ad7b-cb88f476ad3f digest=76b094f58485586be3597f63ed6d09ebedc6ff5e46f8bf68dccf2b3aec0378ef]\n\n---\n\n`bell` で親として参加したよ(endpoint `ff26e7b4-953c-408f-b47f-f87e32ce4daf`)。上の配送は `parent_read` で回収済みで、継続tokenは出ていないから、これで全文。次の待機プロセスも返却されたinputを一字も変えずに登録してあるから、このまま次の配送を待つね。\n\n1回目の起動で、私がスクリプトのパスを書き換えてしまったの(`proj/../f-prefix` になっていた)。モジュールが見つからずにすぐ終了したから、何も動いていないし副作用もないよ。そのあと返却されたinputどおりのコマンドで起動し直してある。", + "order": 9, + "turn_id": "d25ee893-88c5-4ad7-a316-3df60037f7c9", + "entry_uuid": null + }, + "spool": { + "delivery_id": "e0799ebb-ddca-41f5-ad7b-cb88f476ad3f", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "cursor_native_background_output" + }, + "receipt": { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "cursor_native_background_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + "tui_full_body_present": true + } + ], + "negative_controls": [ + { + "condition": "other_to_other_dm(worker->peer2)", + "seq": 6, + "room_delivery_keys": [ + "peer2" + ], + "bell_in_room_deliveries": false, + "in_bell_spool": false, + "received_in_harness_record": 0, + "reply_mentions": 0 + }, + { + "condition": "parent_self_post(bell->all)", + "seq": 8, + "room_delivery_keys": [ + "worker", + "peer2" + ], + "bell_in_room_deliveries": false, + "in_bell_spool": false, + "received_in_harness_record": 0, + "reply_mentions": 0 + } + ], + "negative_controls_ok": true, + "tui_full_body_present": { + "dm": true, + "multiple": true, + "all": true + }, + "native_task_records": [ + { + "task_id": "910374", + "pid": "84622", + "status": "failed", + "started_at": "2026-09-28T17:50:31.836Z" + }, + { + "task_id": "910375", + "pid": "85503", + "status": "succeeded", + "started_at": "2026-09-28T17:50:47.601Z" + }, + { + "task_id": "910376", + "pid": "86539", + "status": "succeeded", + "started_at": "2026-09-28T17:50:58.164Z" + }, + { + "task_id": "910377", + "pid": "90554", + "status": "succeeded", + "started_at": "2026-09-28T17:52:17.171Z" + }, + { + "task_id": "910378", + "pid": "93197", + "status": "running", + "started_at": "2026-09-28T17:52:57.499Z" + } + ], + "hook_events": { + "total": 31, + "nonzero_exit": 0, + "stdin_prefix_hex": [ + "7b22636f" + ] + }, + "task_output_reader": "model Read of the Cursor-saved terminals/.txt (native task output file) then parent_read", + "evidence_files": { + "state": "/private/tmp/claude-501/qa-cursor-grok/f-mac-cursor/final-state.json", + "hook_events": "/private/tmp/claude-501/qa-cursor-grok/f-mac-cursor/hook-events.jsonl", + "tui": "/private/tmp/claude-501/qa-cursor-grok/f-mac-cursor/tui-scrollback.txt" + } +} \ No newline at end of file diff --git a/rag/parent-delivery/live/darwin/grok/cli/audience.json b/rag/parent-delivery/live/darwin/grok/cli/audience.json new file mode 100644 index 00000000..4ca10fda --- /dev/null +++ b/rag/parent-delivery/live/darwin/grok/cli/audience.json @@ -0,0 +1,497 @@ +{ + "schema": "peertable.parent-live-case.v1", + "case_id": "darwin/grok/cli/audience", + "status": "passed", + "reasons": [], + "native": true, + "os": "darwin", + "harness": "grok", + "surface": "cli", + "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8", + "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", + "package_version": "0.8.63", + "client_version": "0.8.63", + "package_tarball_sha256": "55f7f8789b24f4a316b9b67414b5e96a63a2422110085215dcb5b485c58e6fc6", + "harness_version": "1.0.41 (4220f3b224a6)", + "node_version": "v26.10.0", + "parent_session": "01a0e926-e47b-7a01-b2ca-cd3b02d99d55", + "parent_process": { + "pid": 95304, + "parent": 95124, + "started": "Tue Sep 29 02:53:51 2026", + "executable": "~/.grok/downloads/grok-macos-aarch64", + "command": "~/.grok/bin/grok --always-approve" + }, + "endpoint_id": "0fc6e555-be36-463d-9f85-acd591bcf3e0", + "room": "qa-cg", + "owner_input_required": false, + "isolation": { + "fixture": "dedicated room server + token file, dedicated private QA dir", + "home": "normal HOME/auth (no fake HOME)", + "harness_config": "project .cursor/.grok fixture hooks+MCP (no global connect)", + "install": "npm pack tarball installed with npm install --prefix; product room/client/hook run from the installed package", + "model": "Grok 4.6 (high)", + "permission": "--always-approve + /hooks-trust" + }, + "observations": [ + { + "kind": "parent_join", + "parent_session": "01a0e926-e47b-7a01-b2ca-cd3b02d99d55", + "turn_id": "cffbf56c-b920-4be2-b750-c026cc3f09bc", + "tool_use_id": "call-98c283cf-9845-4b71-9078-12dbe5561cb0-2", + "endpoint_id": "0fc6e555-be36-463d-9f85-acd591bcf3e0", + "caller_owner": { + "pid": 95304, + "parent": 95124, + "started": "Tue Sep 29 02:53:51 2026", + "executable": "~/.grok/downloads/grok-macos-aarch64", + "command": "~/.grok/bin/grok --always-approve" + }, + "call_generation": "7eb063ce-ed2f-4e59-a1e5-0bb41628c733" + }, + { + "kind": "native_background_task_armed", + "parent_session": "01a0e926-e47b-7a01-b2ca-cd3b02d99d55", + "turn_id": "cffbf56c-b920-4be2-b750-c026cc3f09bc", + "task_id": "01a0e929-8a17-7392-b05e-a0910dff2da8", + "task_pid": 1337, + "task_process_started": "Tue Sep 29 02:56:48 2026", + "waiter_pid": 1341, + "waiter_started": "Tue Sep 29 02:56:48 2026", + "runtime": "armed", + "task_id_transitions": [ + "POSITIVE A \"seq\":4 task 01a0e928-1f3d-73b2-b91f-31c97b701f0e -> 01a0e928-a148-7833-b11a-e56906006e72", + "POSITIVE D \"seq\":5 task 01a0e928-a148-7833-b11a-e56906006e72 -> 01a0e928-e760-7b60-9885-384c77000b97", + "NEGATIVE X \"seq\":6 (expect no delivery to bell)", + "POSITIVE M \"seq\":7 task 01a0e928-e760-7b60-9885-384c77000b97 -> 01a0e929-8a17-7392-b05e-a0910dff2da8", + "NEGATIVE S \"seq\":8 (expect no delivery to bell)", + "STATE 01a0e929-8a17-7392-b05e-a0910dff2da8" + ] + }, + { + "kind": "delivery_in_parent_conversation", + "label": "dm", + "parent_session": "01a0e926-e47b-7a01-b2ca-cd3b02d99d55", + "turn_id": "task-completed-01a0e928-a148-7833-b11a-e56906006e72", + "seq": 5, + "delivery_id": "68015acd-e540-4d6a-bc89-f8289d9115e5", + "entry": { + "type": "tool_call_update", + "id": "call-0fe2f08c-df0c-4311-96fc-b4d13aa45b8a-17" + }, + "boundary": { + "source": "grok parent_read MCP result (transcript updates.jsonl)", + "raw_body_equal": true + } + }, + { + "kind": "parent_reply_after_delivery", + "label": "dm", + "parent_session": "01a0e926-e47b-7a01-b2ca-cd3b02d99d55", + "turn_id": "task-completed-01a0e928-a148-7833-b11a-e56906006e72", + "seq": 5, + "reply_excerpt": "受信:QA-D-MGRK: 親だけ宛のDMです。符号 DM-MGRK" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "multiple", + "parent_session": "01a0e926-e47b-7a01-b2ca-cd3b02d99d55", + "turn_id": "task-completed-01a0e928-e760-7b60-9885-384c77000b97", + "seq": 7, + "delivery_id": "dd4d7ad9-731c-4997-8005-2dcb18843e99", + "entry": { + "type": "tool_call_update", + "id": "call-998ccf6b-5350-4047-aa9b-6ffa982ddb0f-20" + }, + "boundary": { + "source": "grok parent_read MCP result (transcript updates.jsonl)", + "raw_body_equal": true + } + }, + { + "kind": "parent_reply_after_delivery", + "label": "multiple", + "parent_session": "01a0e926-e47b-7a01-b2ca-cd3b02d99d55", + "turn_id": "task-completed-01a0e928-e760-7b60-9885-384c77000b97", + "seq": 7, + "reply_excerpt": "受信:QA-M-MGRK: 親とpeer2宛の複数宛です。符号 MULTI-MGRK" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "all", + "parent_session": "01a0e926-e47b-7a01-b2ca-cd3b02d99d55", + "turn_id": "task-completed-01a0e928-1f3d-73b2-b91f-31c97b701f0e", + "seq": 4, + "delivery_id": "863b05eb-e535-4368-bbe0-3033d8bfc867", + "entry": { + "type": "tool_call_update", + "id": "call-c84139e3-fd53-4e00-9772-17722628af79-14" + }, + "boundary": { + "source": "grok parent_read MCP result (transcript updates.jsonl)", + "raw_body_equal": true + } + }, + { + "kind": "parent_reply_after_delivery", + "label": "all", + "parent_session": "01a0e926-e47b-7a01-b2ca-cd3b02d99d55", + "turn_id": "task-completed-01a0e928-1f3d-73b2-b91f-31c97b701f0e", + "seq": 4, + "reply_excerpt": "受信:QA-A-MGRK: 全員宛の確認です。符号 ALL-MGRK" + } + ], + "receipts": [ + { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "parent_read_complete", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + { + "seq": 7, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "parent_read_complete", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "parent_read_complete", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + } + ], + "body_checks": [ + { + "label": "dm", + "original": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-MGRK: 親だけ宛のDMです。符号 DM-MGRK" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-MGRK: 親だけ宛のDMです。符号 DM-MGRK" + } + }, + { + "label": "multiple", + "original": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-MGRK: 親とpeer2宛の複数宛です。符号 MULTI-MGRK" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-MGRK: 親とpeer2宛の複数宛です。符号 MULTI-MGRK" + } + }, + { + "label": "all", + "original": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-MGRK: 全員宛の確認です。符号 ALL-MGRK" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-MGRK: 全員宛の確認です。符号 ALL-MGRK" + } + } + ], + "checks": [ + { + "status": "passed", + "reasons": [], + "label": "dm", + "recipient": "bell", + "original": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-MGRK: 親だけ宛のDMです。符号 DM-MGRK" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-MGRK: 親だけ宛のDMです。符号 DM-MGRK" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-MGRK: 親だけ宛のDMです。符号 DM-MGRK", + "delivery_id": "68015acd-e540-4d6a-bc89-f8289d9115e5", + "digest": "0c8c04b6b3d04e9a02b2ecffbaea1f55dafe2488b8b97fdd8093ffd4d01230c1", + "order": 152, + "session": "01a0e926-e47b-7a01-b2ca-cd3b02d99d55", + "turn_id": "task-completed-01a0e928-a148-7833-b11a-e56906006e72", + "boundary": { + "source": "grok parent_read MCP result (transcript updates.jsonl)", + "raw_body_equal": true + }, + "entry": { + "type": "tool_call_update", + "id": "call-0fe2f08c-df0c-4311-96fc-b4d13aa45b8a-17" + } + }, + "reply": { + "session": "01a0e926-e47b-7a01-b2ca-cd3b02d99d55", + "text": "受信:QA-D-MGRK: 親だけ宛のDMです。符号 DM-MGRK", + "order": 162, + "turn_id": "task-completed-01a0e928-a148-7833-b11a-e56906006e72", + "entry_uuid": null + }, + "spool": { + "delivery_id": "68015acd-e540-4d6a-bc89-f8289d9115e5", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "parent_read_complete" + }, + "receipt": { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "parent_read_complete", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + "tui_full_body_present": true + }, + { + "status": "passed", + "reasons": [], + "label": "multiple", + "recipient": "bell", + "original": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-MGRK: 親とpeer2宛の複数宛です。符号 MULTI-MGRK" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-MGRK: 親とpeer2宛の複数宛です。符号 MULTI-MGRK" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-MGRK: 親とpeer2宛の複数宛です。符号 MULTI-MGRK", + "delivery_id": "dd4d7ad9-731c-4997-8005-2dcb18843e99", + "digest": "8ac76f23099bd6018799de90b21faf11b93fde519c6a37355c9a06cc3d34e533", + "order": 177, + "session": "01a0e926-e47b-7a01-b2ca-cd3b02d99d55", + "turn_id": "task-completed-01a0e928-e760-7b60-9885-384c77000b97", + "boundary": { + "source": "grok parent_read MCP result (transcript updates.jsonl)", + "raw_body_equal": true + }, + "entry": { + "type": "tool_call_update", + "id": "call-998ccf6b-5350-4047-aa9b-6ffa982ddb0f-20" + } + }, + "reply": { + "session": "01a0e926-e47b-7a01-b2ca-cd3b02d99d55", + "text": "受信:QA-M-MGRK: 親とpeer2宛の複数宛です。符号 MULTI-MGRK", + "order": 187, + "turn_id": "task-completed-01a0e928-e760-7b60-9885-384c77000b97", + "entry_uuid": null + }, + "spool": { + "delivery_id": "dd4d7ad9-731c-4997-8005-2dcb18843e99", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "parent_read_complete" + }, + "receipt": { + "seq": 7, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "parent_read_complete", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + "tui_full_body_present": true + }, + { + "status": "passed", + "reasons": [], + "label": "all", + "recipient": "bell", + "original": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-MGRK: 全員宛の確認です。符号 ALL-MGRK" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-MGRK: 全員宛の確認です。符号 ALL-MGRK" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-MGRK: 全員宛の確認です。符号 ALL-MGRK", + "delivery_id": "863b05eb-e535-4368-bbe0-3033d8bfc867", + "digest": "b46e44ea1a545ec887f0835641d763237aa1d4d641b5b84e5a771f55210fde04", + "order": 128, + "session": "01a0e926-e47b-7a01-b2ca-cd3b02d99d55", + "turn_id": "task-completed-01a0e928-1f3d-73b2-b91f-31c97b701f0e", + "boundary": { + "source": "grok parent_read MCP result (transcript updates.jsonl)", + "raw_body_equal": true + }, + "entry": { + "type": "tool_call_update", + "id": "call-c84139e3-fd53-4e00-9772-17722628af79-14" + } + }, + "reply": { + "session": "01a0e926-e47b-7a01-b2ca-cd3b02d99d55", + "text": "受信:QA-A-MGRK: 全員宛の確認です。符号 ALL-MGRK", + "order": 137, + "turn_id": "task-completed-01a0e928-1f3d-73b2-b91f-31c97b701f0e", + "entry_uuid": null + }, + "spool": { + "delivery_id": "863b05eb-e535-4368-bbe0-3033d8bfc867", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "parent_read_complete" + }, + "receipt": { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "parent_read_complete", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + "tui_full_body_present": true + } + ], + "negative_controls": [ + { + "condition": "other_to_other_dm(worker->peer2)", + "seq": 6, + "room_delivery_keys": [ + "peer2" + ], + "bell_in_room_deliveries": false, + "in_bell_spool": false, + "received_in_harness_record": 0, + "reply_mentions": 0 + }, + { + "condition": "parent_self_post(bell->all)", + "seq": 8, + "room_delivery_keys": [ + "worker", + "peer2" + ], + "bell_in_room_deliveries": false, + "in_bell_spool": false, + "received_in_harness_record": 0, + "reply_mentions": 0 + } + ], + "negative_controls_ok": true, + "tui_full_body_present": { + "dm": true, + "multiple": true, + "all": true + }, + "native_task_records": [ + { + "background_started": 7, + "background_completed": 6 + } + ], + "hook_events": { + "total": 44, + "nonzero_exit": 0, + "stdin_prefix_hex": [ + "7b22686f" + ] + }, + "task_output_reader": "get_command_or_subagent_output(task_id, no timeout_ms) then parent_read", + "evidence_files": { + "state": "/private/tmp/claude-501/qa-cursor-grok/f-mac-grok/final-state.json", + "hook_events": "/private/tmp/claude-501/qa-cursor-grok/f-mac-grok/hook-events.jsonl", + "tui": "/private/tmp/claude-501/qa-cursor-grok/f-mac-grok/tui-scrollback.txt" + } +} \ No newline at end of file diff --git a/rag/parent-delivery/live/linux/cursor/cli/audience.json b/rag/parent-delivery/live/linux/cursor/cli/audience.json new file mode 100644 index 00000000..94a657b4 --- /dev/null +++ b/rag/parent-delivery/live/linux/cursor/cli/audience.json @@ -0,0 +1,520 @@ +{ + "schema": "peertable.parent-live-case.v1", + "case_id": "linux/cursor/cli/audience", + "status": "passed", + "reasons": [], + "native": true, + "os": "linux", + "harness": "cursor", + "surface": "cli", + "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8", + "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", + "package_version": "0.8.63", + "client_version": "0.8.63", + "package_tarball_sha256": "55f7f8789b24f4a316b9b67414b5e96a63a2422110085215dcb5b485c58e6fc6", + "harness_version": "2026.09.26-dd393fe", + "node_version": "v24.20.0", + "parent_session": "78326524-689e-4921-b983-4cb13b717a36", + "parent_process": { + "pid": 1596115, + "parent": 1596114, + "started": "fa227cc0-c7c5-4c8d-bc23-b56674f51d33:53740758", + "executable": "/home/kite/.local/share/cursor-agent/versions/2026.09.26-dd393fe/node", + "command": "/home/kite/.local/bin/cursor-agent --use-system-ca /home/kite/.local/share/cursor-agent/versions/2026.09.26-dd393fe/index.js --model auto --trust --approve-mcps --force " + }, + "endpoint_id": "f451c0e4-c168-42e8-bc05-9d0a5d51328e", + "room": "qa-cg", + "owner_input_required": false, + "isolation": { + "fixture": "dedicated room server + token file, dedicated private QA dir", + "home": "normal HOME/auth (no fake HOME)", + "harness_config": "project .cursor/.grok fixture hooks+MCP (no global connect)", + "install": "npm pack tarball installed with npm install --prefix; product room/client/hook run from the installed package", + "model": "Auto", + "permission": "--force --approve-mcps --trust" + }, + "observations": [ + { + "kind": "parent_join", + "parent_session": "78326524-689e-4921-b983-4cb13b717a36", + "turn_id": "b38de67c-3312-45a9-9337-7fc6c6484a4e", + "tool_use_id": "36b8a2de-60da-4062-8cfa-aea3c8bfaae6", + "endpoint_id": "f451c0e4-c168-42e8-bc05-9d0a5d51328e", + "caller_owner": { + "pid": 1596115, + "parent": 1596114, + "started": "fa227cc0-c7c5-4c8d-bc23-b56674f51d33:53740758", + "executable": "/home/kite/.local/share/cursor-agent/versions/2026.09.26-dd393fe/node", + "command": "/home/kite/.local/bin/cursor-agent --use-system-ca /home/kite/.local/share/cursor-agent/versions/2026.09.26-dd393fe/index.js --model auto --trust --approve-mcps --force " + }, + "call_generation": "b69b85f2-3263-46ff-b7b7-300db3a97e7d" + }, + { + "kind": "native_background_task_armed", + "parent_session": "78326524-689e-4921-b983-4cb13b717a36", + "turn_id": "b38de67c-3312-45a9-9337-7fc6c6484a4e", + "task_id": "865995", + "task_pid": 1602462, + "task_process_started": "fa227cc0-c7c5-4c8d-bc23-b56674f51d33:53750121", + "waiter_pid": 1602548, + "waiter_started": "fa227cc0-c7c5-4c8d-bc23-b56674f51d33:53750134", + "runtime": "armed", + "task_id_transitions": [ + "POSITIVE A \"seq\":4 task 865992 -> 865993", + "POSITIVE D \"seq\":5 task 865993 -> 865994", + "NEGATIVE X \"seq\":6 (expect no delivery to bell)", + "POSITIVE M \"seq\":7 task 865994 -> 865995", + "NEGATIVE S \"seq\":8 (expect no delivery to bell)", + "STATE 865995" + ] + }, + { + "kind": "delivery_in_parent_conversation", + "label": "dm", + "parent_session": "78326524-689e-4921-b983-4cb13b717a36", + "turn_id": "e3ef1a60-fe2d-450d-bd07-c40070476bbb", + "seq": 5, + "delivery_id": "59a06eb4-e65d-4fc3-92d3-a6e9f92470ee", + "entry": { + "type": "native_task_output_file", + "task_id": "865993" + }, + "boundary": { + "source": "cursor native background task output terminals/865993.txt", + "raw_body_equal": true + } + }, + { + "kind": "parent_reply_after_delivery", + "label": "dm", + "parent_session": "78326524-689e-4921-b983-4cb13b717a36", + "turn_id": "e3ef1a60-fe2d-450d-bd07-c40070476bbb", + "seq": 5, + "reply_excerpt": "受信:\n[Peertable room=qa-cg from=worker to=\"bell\" seq=5]\n本文: QA-D-LCUR: 親だけ宛のDMです。符号 DM-LCUR\n[配送ID=59a06eb4-e65d-4fc3-92d3-a6e9f92470ee digest=2d1adf01701f70d56e517e766c24921dd40ee9aa5222afed8225bdda2ee" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "multiple", + "parent_session": "78326524-689e-4921-b983-4cb13b717a36", + "turn_id": "a8328b73-c64f-4066-ae10-6bfc0125f7ef", + "seq": 7, + "delivery_id": "246fc5ff-409a-4685-afae-4f84eb355bf6", + "entry": { + "type": "native_task_output_file", + "task_id": "865994" + }, + "boundary": { + "source": "cursor native background task output terminals/865994.txt", + "raw_body_equal": true + } + }, + { + "kind": "parent_reply_after_delivery", + "label": "multiple", + "parent_session": "78326524-689e-4921-b983-4cb13b717a36", + "turn_id": "a8328b73-c64f-4066-ae10-6bfc0125f7ef", + "seq": 7, + "reply_excerpt": "受信:\n[Peertable room=qa-cg from=worker to=[\"bell\",\"peer2\"] seq=7]\n本文: QA-M-LCUR: 親とpeer2宛の複数宛です。符号 MULTI-LCUR\n[配送ID=246fc5ff-409a-4685-afae-4f84eb355bf6 digest=a6a2d6319918deb306d6bed680b55503b3b21c110" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "all", + "parent_session": "78326524-689e-4921-b983-4cb13b717a36", + "turn_id": "51e7c13b-ecac-4f30-bc6a-2e455c91409c", + "seq": 4, + "delivery_id": "f24c99c9-4169-4678-b05d-67046fe42eec", + "entry": { + "type": "native_task_output_file", + "task_id": "865992" + }, + "boundary": { + "source": "cursor native background task output terminals/865992.txt", + "raw_body_equal": true + } + }, + { + "kind": "parent_reply_after_delivery", + "label": "all", + "parent_session": "78326524-689e-4921-b983-4cb13b717a36", + "turn_id": "51e7c13b-ecac-4f30-bc6a-2e455c91409c", + "seq": 4, + "reply_excerpt": "受信:\n[Peertable room=qa-cg from=worker to=\"all\" seq=4]\n本文: QA-A-LCUR: 全員宛の確認です。符号 ALL-LCUR\n[配送ID=f24c99c9-4169-4678-b05d-67046fe42eec digest=641a895963c0c950c4598e3cdf8b5cce0f2156243ee468adcd8d5ae02da7" + } + ], + "receipts": [ + { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "cursor_native_background_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + { + "seq": 7, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "cursor_native_background_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "cursor_native_background_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + } + ], + "body_checks": [ + { + "label": "dm", + "original": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-LCUR: 親だけ宛のDMです。符号 DM-LCUR" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-LCUR: 親だけ宛のDMです。符号 DM-LCUR" + } + }, + { + "label": "multiple", + "original": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-LCUR: 親とpeer2宛の複数宛です。符号 MULTI-LCUR" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-LCUR: 親とpeer2宛の複数宛です。符号 MULTI-LCUR" + } + }, + { + "label": "all", + "original": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-LCUR: 全員宛の確認です。符号 ALL-LCUR" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-LCUR: 全員宛の確認です。符号 ALL-LCUR" + } + } + ], + "checks": [ + { + "status": "passed", + "reasons": [], + "label": "dm", + "recipient": "bell", + "original": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-LCUR: 親だけ宛のDMです。符号 DM-LCUR" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-LCUR: 親だけ宛のDMです。符号 DM-LCUR" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-LCUR: 親だけ宛のDMです。符号 DM-LCUR", + "delivery_id": "59a06eb4-e65d-4fc3-92d3-a6e9f92470ee", + "digest": "2d1adf01701f70d56e517e766c24921dd40ee9aa5222afed8225bdda2eefed97", + "preview": false, + "order": 11, + "session": "78326524-689e-4921-b983-4cb13b717a36", + "turn_id": "e3ef1a60-fe2d-450d-bd07-c40070476bbb", + "boundary": { + "source": "cursor native background task output terminals/865993.txt", + "raw_body_equal": true + }, + "entry": { + "type": "native_task_output_file", + "task_id": "865993" + } + }, + "reply": { + "session": "78326524-689e-4921-b983-4cb13b717a36", + "text": "受信:\n[Peertable room=qa-cg from=worker to=\"bell\" seq=5]\n本文: QA-D-LCUR: 親だけ宛のDMです。符号 DM-LCUR\n[配送ID=59a06eb4-e65d-4fc3-92d3-a6e9f92470ee digest=2d1adf01701f70d56e517e766c24921dd40ee9aa5222afed8225bdda2eefed97]\n\n`parent_read` で配送 `59a06eb4-…` の回収を確定させたよ。今回も `parent_read` の本文は空(`page: null`)だった。だから上の全文は、待機プロセスの完了出力にあった `text` をそのまま写したもの。次の待機プロセスも登録し直したから、また待つね。", + "order": 14, + "turn_id": "e3ef1a60-fe2d-450d-bd07-c40070476bbb", + "entry_uuid": null + }, + "spool": { + "delivery_id": "59a06eb4-e65d-4fc3-92d3-a6e9f92470ee", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "cursor_native_background_output" + }, + "receipt": { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "cursor_native_background_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + "tui_full_body_present": true + }, + { + "status": "passed", + "reasons": [], + "label": "multiple", + "recipient": "bell", + "original": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-LCUR: 親とpeer2宛の複数宛です。符号 MULTI-LCUR" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-LCUR: 親とpeer2宛の複数宛です。符号 MULTI-LCUR" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-LCUR: 親とpeer2宛の複数宛です。符号 MULTI-LCUR", + "delivery_id": "246fc5ff-409a-4685-afae-4f84eb355bf6", + "digest": "a6a2d6319918deb306d6bed680b55503b3b21c1108d0b25e44a9fb9919cffae1", + "preview": false, + "order": 16, + "session": "78326524-689e-4921-b983-4cb13b717a36", + "turn_id": "a8328b73-c64f-4066-ae10-6bfc0125f7ef", + "boundary": { + "source": "cursor native background task output terminals/865994.txt", + "raw_body_equal": true + }, + "entry": { + "type": "native_task_output_file", + "task_id": "865994" + } + }, + "reply": { + "session": "78326524-689e-4921-b983-4cb13b717a36", + "text": "受信:\n[Peertable room=qa-cg from=worker to=[\"bell\",\"peer2\"] seq=7]\n本文: QA-M-LCUR: 親とpeer2宛の複数宛です。符号 MULTI-LCUR\n[配送ID=246fc5ff-409a-4685-afae-4f84eb355bf6 digest=a6a2d6319918deb306d6bed680b55503b3b21c1108d0b25e44a9fb9919cffae1]\n\n`parent_read` で配送 `246fc5ff-…` の回収を確定させたよ。今回も `parent_read` の本文は空(`page: null`)だった。だから上の全文は、待機プロセスの完了出力にあった `text` をそのまま写したもの。次の待機プロセスも登録し直したから、また待つね。", + "order": 19, + "turn_id": "a8328b73-c64f-4066-ae10-6bfc0125f7ef", + "entry_uuid": null + }, + "spool": { + "delivery_id": "246fc5ff-409a-4685-afae-4f84eb355bf6", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "cursor_native_background_output" + }, + "receipt": { + "seq": 7, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "cursor_native_background_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + "tui_full_body_present": true + }, + { + "status": "passed", + "reasons": [], + "label": "all", + "recipient": "bell", + "original": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-LCUR: 全員宛の確認です。符号 ALL-LCUR" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-LCUR: 全員宛の確認です。符号 ALL-LCUR" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-LCUR: 全員宛の確認です。符号 ALL-LCUR", + "delivery_id": "f24c99c9-4169-4678-b05d-67046fe42eec", + "digest": "641a895963c0c950c4598e3cdf8b5cce0f2156243ee468adcd8d5ae02da7a01f", + "preview": false, + "order": 6, + "session": "78326524-689e-4921-b983-4cb13b717a36", + "turn_id": "51e7c13b-ecac-4f30-bc6a-2e455c91409c", + "boundary": { + "source": "cursor native background task output terminals/865992.txt", + "raw_body_equal": true + }, + "entry": { + "type": "native_task_output_file", + "task_id": "865992" + } + }, + "reply": { + "session": "78326524-689e-4921-b983-4cb13b717a36", + "text": "受信:\n[Peertable room=qa-cg from=worker to=\"all\" seq=4]\n本文: QA-A-LCUR: 全員宛の確認です。符号 ALL-LCUR\n[配送ID=f24c99c9-4169-4678-b05d-67046fe42eec digest=641a895963c0c950c4598e3cdf8b5cce0f2156243ee468adcd8d5ae02da7a01f]\n\n`parent_read` で配送 `f24c99c9-…` の回収を確定させたよ。ただ、`parent_read` の返り値は本文が空(`page: null`)だったの。だから上に貼った全文は、待機プロセスの完了出力にあった `text` をそのまま写したもの。次の待機プロセスも同じ手順で登録し直したから、また待つね。\n\nMy final message needs to begin with 「受信:」.\n\n", + "order": 9, + "turn_id": "51e7c13b-ecac-4f30-bc6a-2e455c91409c", + "entry_uuid": null + }, + "spool": { + "delivery_id": "f24c99c9-4169-4678-b05d-67046fe42eec", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "cursor_native_background_output" + }, + "receipt": { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "cursor_native_background_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + "tui_full_body_present": true + } + ], + "negative_controls": [ + { + "condition": "other_to_other_dm(worker->peer2)", + "seq": 6, + "room_delivery_keys": [ + "peer2" + ], + "bell_in_room_deliveries": false, + "in_bell_spool": false, + "received_in_harness_record": 0, + "reply_mentions": 0 + }, + { + "condition": "parent_self_post(bell->all)", + "seq": 8, + "room_delivery_keys": [ + "worker", + "peer2" + ], + "bell_in_room_deliveries": false, + "in_bell_spool": false, + "received_in_harness_record": 0, + "reply_mentions": 0 + } + ], + "negative_controls_ok": true, + "tui_full_body_present": { + "dm": true, + "multiple": true, + "all": true + }, + "native_task_records": [ + { + "task_id": "865992", + "pid": "1597967", + "status": "succeeded", + "started_at": "2026-09-28T17:58:08.989Z" + }, + { + "task_id": "865993", + "pid": "1599504", + "status": "succeeded", + "started_at": "2026-09-28T17:58:24.750Z" + }, + { + "task_id": "865994", + "pid": "1600935", + "status": "succeeded", + "started_at": "2026-09-28T17:58:39.603Z" + }, + { + "task_id": "865995", + "pid": "1602462", + "status": "running", + "started_at": "2026-09-28T17:59:19.317Z" + } + ], + "hook_events": { + "total": 26, + "nonzero_exit": 0, + "stdin_prefix_hex": [ + "7b22636f" + ] + }, + "task_output_reader": "model Read of the Cursor-saved terminals/.txt (native task output file) then parent_read", + "evidence_files": { + "state": "/private/tmp/claude-501/qa-cursor-grok/f-linux-cursor/final-state.json", + "hook_events": "/private/tmp/claude-501/qa-cursor-grok/f-linux-cursor/hook-events.jsonl", + "tui": "/private/tmp/claude-501/qa-cursor-grok/f-linux-cursor/tui-scrollback.txt" + } +} \ No newline at end of file diff --git a/rag/parent-delivery/live/linux/grok/cli/audience.json b/rag/parent-delivery/live/linux/grok/cli/audience.json new file mode 100644 index 00000000..aee2a3d3 --- /dev/null +++ b/rag/parent-delivery/live/linux/grok/cli/audience.json @@ -0,0 +1,497 @@ +{ + "schema": "peertable.parent-live-case.v1", + "case_id": "linux/grok/cli/audience", + "status": "passed", + "reasons": [], + "native": true, + "os": "linux", + "harness": "grok", + "surface": "cli", + "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8", + "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", + "package_version": "0.8.63", + "client_version": "0.8.63", + "package_tarball_sha256": "55f7f8789b24f4a316b9b67414b5e96a63a2422110085215dcb5b485c58e6fc6", + "harness_version": "1.0.41 (4220f3b224a6)", + "node_version": "v24.20.0", + "parent_session": "01a0e92c-b15c-7b73-a7cb-dea3fa2e1072", + "parent_process": { + "pid": 1603454, + "parent": 1603453, + "started": "fa227cc0-c7c5-4c8d-bc23-b56674f51d33:53755445", + "executable": "/home/kite/.grok/downloads/grok-linux-x86_64", + "command": "grok --always-approve " + }, + "endpoint_id": "f778be43-0b58-46ce-91fe-b14a4ea95fcd", + "room": "qa-cg", + "owner_input_required": false, + "isolation": { + "fixture": "dedicated room server + token file, dedicated private QA dir", + "home": "normal HOME/auth (no fake HOME)", + "harness_config": "project .cursor/.grok fixture hooks+MCP (no global connect)", + "install": "npm pack tarball installed with npm install --prefix; product room/client/hook run from the installed package", + "model": "Grok 4.6 (high)", + "permission": "--always-approve + /hooks-trust" + }, + "observations": [ + { + "kind": "parent_join", + "parent_session": "01a0e92c-b15c-7b73-a7cb-dea3fa2e1072", + "turn_id": "b615f19d-35b7-4303-a3ee-f6d73bec538d", + "tool_use_id": "call-5721826f-73ff-4df4-a60d-9c8398bb8228-2", + "endpoint_id": "f778be43-0b58-46ce-91fe-b14a4ea95fcd", + "caller_owner": { + "pid": 1603454, + "parent": 1603453, + "started": "fa227cc0-c7c5-4c8d-bc23-b56674f51d33:53755445", + "executable": "/home/kite/.grok/downloads/grok-linux-x86_64", + "command": "grok --always-approve " + }, + "call_generation": "762f29ee-df44-43a6-9a3a-37e846544325" + }, + { + "kind": "native_background_task_armed", + "parent_session": "01a0e92c-b15c-7b73-a7cb-dea3fa2e1072", + "turn_id": "b615f19d-35b7-4303-a3ee-f6d73bec538d", + "task_id": "01a0e92f-c051-7701-bb48-7df24212c655", + "task_pid": 1608995, + "task_process_started": "fa227cc0-c7c5-4c8d-bc23-b56674f51d33:53775777", + "waiter_pid": 1609000, + "waiter_started": "fa227cc0-c7c5-4c8d-bc23-b56674f51d33:53775778", + "runtime": "armed", + "task_id_transitions": [ + "POSITIVE A \"seq\":4 task 01a0e92e-25a3-7790-9f86-22b320089ed2 -> 01a0e92e-a990-72f3-b028-39ae46b35b32", + "POSITIVE D \"seq\":5 task 01a0e92e-a990-72f3-b028-39ae46b35b32 -> 01a0e92f-0041-7ff0-81b8-65bbe157d8a0", + "NEGATIVE X \"seq\":6 (expect no delivery to bell)", + "POSITIVE M \"seq\":7 task 01a0e92f-0041-7ff0-81b8-65bbe157d8a0 -> 01a0e92f-c051-7701-bb48-7df24212c655", + "NEGATIVE S \"seq\":8 (expect no delivery to bell)", + "STATE 01a0e92f-c051-7701-bb48-7df24212c655" + ] + }, + { + "kind": "delivery_in_parent_conversation", + "label": "dm", + "parent_session": "01a0e92c-b15c-7b73-a7cb-dea3fa2e1072", + "turn_id": "task-completed-01a0e92e-a990-72f3-b028-39ae46b35b32", + "seq": 5, + "delivery_id": "e4426e43-48e8-4566-9b87-fe1df51d8b34", + "entry": { + "type": "tool_call_update", + "id": "call-4f88dd7c-4626-4efa-9d24-c96311ab3cdc-17" + }, + "boundary": { + "source": "grok parent_read MCP result (transcript updates.jsonl)", + "raw_body_equal": true + } + }, + { + "kind": "parent_reply_after_delivery", + "label": "dm", + "parent_session": "01a0e92c-b15c-7b73-a7cb-dea3fa2e1072", + "turn_id": "task-completed-01a0e92e-a990-72f3-b028-39ae46b35b32", + "seq": 5, + "reply_excerpt": "受信: QA-D-LGRK: 親だけ宛のDMです。符号 DM-LGRK" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "multiple", + "parent_session": "01a0e92c-b15c-7b73-a7cb-dea3fa2e1072", + "turn_id": "task-completed-01a0e92f-0041-7ff0-81b8-65bbe157d8a0", + "seq": 7, + "delivery_id": "35529b48-74e0-4563-8f82-5348cb7acc6f", + "entry": { + "type": "tool_call_update", + "id": "call-97564494-7cf4-46fc-8a60-97211e167369-20" + }, + "boundary": { + "source": "grok parent_read MCP result (transcript updates.jsonl)", + "raw_body_equal": true + } + }, + { + "kind": "parent_reply_after_delivery", + "label": "multiple", + "parent_session": "01a0e92c-b15c-7b73-a7cb-dea3fa2e1072", + "turn_id": "task-completed-01a0e92f-0041-7ff0-81b8-65bbe157d8a0", + "seq": 7, + "reply_excerpt": "受信: QA-M-LGRK: 親とpeer2宛の複数宛です。符号 MULTI-LGRK" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "all", + "parent_session": "01a0e92c-b15c-7b73-a7cb-dea3fa2e1072", + "turn_id": "task-completed-01a0e92e-25a3-7790-9f86-22b320089ed2", + "seq": 4, + "delivery_id": "24676cee-4807-421e-bb5f-edbeea12a74a", + "entry": { + "type": "tool_call_update", + "id": "call-e6fdde91-3c7d-439e-9799-5895effb69bc-14" + }, + "boundary": { + "source": "grok parent_read MCP result (transcript updates.jsonl)", + "raw_body_equal": true + } + }, + { + "kind": "parent_reply_after_delivery", + "label": "all", + "parent_session": "01a0e92c-b15c-7b73-a7cb-dea3fa2e1072", + "turn_id": "task-completed-01a0e92e-25a3-7790-9f86-22b320089ed2", + "seq": 4, + "reply_excerpt": "受信: QA-A-LGRK: 全員宛の確認です。符号 ALL-LGRK" + } + ], + "receipts": [ + { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "parent_read_complete", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + { + "seq": 7, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "parent_read_complete", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "parent_read_complete", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + } + ], + "body_checks": [ + { + "label": "dm", + "original": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-LGRK: 親だけ宛のDMです。符号 DM-LGRK" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-LGRK: 親だけ宛のDMです。符号 DM-LGRK" + } + }, + { + "label": "multiple", + "original": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-LGRK: 親とpeer2宛の複数宛です。符号 MULTI-LGRK" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-LGRK: 親とpeer2宛の複数宛です。符号 MULTI-LGRK" + } + }, + { + "label": "all", + "original": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-LGRK: 全員宛の確認です。符号 ALL-LGRK" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-LGRK: 全員宛の確認です。符号 ALL-LGRK" + } + } + ], + "checks": [ + { + "status": "passed", + "reasons": [], + "label": "dm", + "recipient": "bell", + "original": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-LGRK: 親だけ宛のDMです。符号 DM-LGRK" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-LGRK: 親だけ宛のDMです。符号 DM-LGRK" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-LGRK: 親だけ宛のDMです。符号 DM-LGRK", + "delivery_id": "e4426e43-48e8-4566-9b87-fe1df51d8b34", + "digest": "289ce886f76e00fb9797c89e5ac415bc5e7a4afc81fd57091405aad0492a6f46", + "order": 153, + "session": "01a0e92c-b15c-7b73-a7cb-dea3fa2e1072", + "turn_id": "task-completed-01a0e92e-a990-72f3-b028-39ae46b35b32", + "boundary": { + "source": "grok parent_read MCP result (transcript updates.jsonl)", + "raw_body_equal": true + }, + "entry": { + "type": "tool_call_update", + "id": "call-4f88dd7c-4626-4efa-9d24-c96311ab3cdc-17" + } + }, + "reply": { + "session": "01a0e92c-b15c-7b73-a7cb-dea3fa2e1072", + "text": "受信: QA-D-LGRK: 親だけ宛のDMです。符号 DM-LGRK", + "order": 163, + "turn_id": "task-completed-01a0e92e-a990-72f3-b028-39ae46b35b32", + "entry_uuid": null + }, + "spool": { + "delivery_id": "e4426e43-48e8-4566-9b87-fe1df51d8b34", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "parent_read_complete" + }, + "receipt": { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "parent_read_complete", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + "tui_full_body_present": true + }, + { + "status": "passed", + "reasons": [], + "label": "multiple", + "recipient": "bell", + "original": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-LGRK: 親とpeer2宛の複数宛です。符号 MULTI-LGRK" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-LGRK: 親とpeer2宛の複数宛です。符号 MULTI-LGRK" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-LGRK: 親とpeer2宛の複数宛です。符号 MULTI-LGRK", + "delivery_id": "35529b48-74e0-4563-8f82-5348cb7acc6f", + "digest": "5ce7bb0fe11262524a5e5795d9eafbb81acf5cf7e9956b5d15174abcf3f5c96a", + "order": 178, + "session": "01a0e92c-b15c-7b73-a7cb-dea3fa2e1072", + "turn_id": "task-completed-01a0e92f-0041-7ff0-81b8-65bbe157d8a0", + "boundary": { + "source": "grok parent_read MCP result (transcript updates.jsonl)", + "raw_body_equal": true + }, + "entry": { + "type": "tool_call_update", + "id": "call-97564494-7cf4-46fc-8a60-97211e167369-20" + } + }, + "reply": { + "session": "01a0e92c-b15c-7b73-a7cb-dea3fa2e1072", + "text": "受信: QA-M-LGRK: 親とpeer2宛の複数宛です。符号 MULTI-LGRK", + "order": 188, + "turn_id": "task-completed-01a0e92f-0041-7ff0-81b8-65bbe157d8a0", + "entry_uuid": null + }, + "spool": { + "delivery_id": "35529b48-74e0-4563-8f82-5348cb7acc6f", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "parent_read_complete" + }, + "receipt": { + "seq": 7, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "parent_read_complete", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + "tui_full_body_present": false + }, + { + "status": "passed", + "reasons": [], + "label": "all", + "recipient": "bell", + "original": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-LGRK: 全員宛の確認です。符号 ALL-LGRK" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-LGRK: 全員宛の確認です。符号 ALL-LGRK" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-LGRK: 全員宛の確認です。符号 ALL-LGRK", + "delivery_id": "24676cee-4807-421e-bb5f-edbeea12a74a", + "digest": "e4b15653ad72cc6b3507f956810130837f2c7fc6f255ca2a62de4edc8c8b9353", + "order": 128, + "session": "01a0e92c-b15c-7b73-a7cb-dea3fa2e1072", + "turn_id": "task-completed-01a0e92e-25a3-7790-9f86-22b320089ed2", + "boundary": { + "source": "grok parent_read MCP result (transcript updates.jsonl)", + "raw_body_equal": true + }, + "entry": { + "type": "tool_call_update", + "id": "call-e6fdde91-3c7d-439e-9799-5895effb69bc-14" + } + }, + "reply": { + "session": "01a0e92c-b15c-7b73-a7cb-dea3fa2e1072", + "text": "受信: QA-A-LGRK: 全員宛の確認です。符号 ALL-LGRK", + "order": 140, + "turn_id": "task-completed-01a0e92e-25a3-7790-9f86-22b320089ed2", + "entry_uuid": null + }, + "spool": { + "delivery_id": "24676cee-4807-421e-bb5f-edbeea12a74a", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "parent_read_complete" + }, + "receipt": { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "parent_read_complete", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + "tui_full_body_present": true + } + ], + "negative_controls": [ + { + "condition": "other_to_other_dm(worker->peer2)", + "seq": 6, + "room_delivery_keys": [ + "peer2" + ], + "bell_in_room_deliveries": false, + "in_bell_spool": false, + "received_in_harness_record": 0, + "reply_mentions": 0 + }, + { + "condition": "parent_self_post(bell->all)", + "seq": 8, + "room_delivery_keys": [ + "worker", + "peer2" + ], + "bell_in_room_deliveries": false, + "in_bell_spool": false, + "received_in_harness_record": 0, + "reply_mentions": 0 + } + ], + "negative_controls_ok": true, + "tui_full_body_present": { + "dm": true, + "multiple": false, + "all": true + }, + "native_task_records": [ + { + "background_started": 7, + "background_completed": 6 + } + ], + "hook_events": { + "total": 44, + "nonzero_exit": 0, + "stdin_prefix_hex": [ + "7b22686f" + ] + }, + "task_output_reader": "get_command_or_subagent_output(task_id, no timeout_ms) then parent_read", + "evidence_files": { + "state": "/private/tmp/claude-501/qa-cursor-grok/f-linux-grok/final-state.json", + "hook_events": "/private/tmp/claude-501/qa-cursor-grok/f-linux-grok/hook-events.jsonl", + "tui": "/private/tmp/claude-501/qa-cursor-grok/f-linux-grok/tui-scrollback.txt" + } +} \ No newline at end of file diff --git a/rag/parent-delivery/live/win32/cursor/cli/audience.json b/rag/parent-delivery/live/win32/cursor/cli/audience.json new file mode 100644 index 00000000..c49bc088 --- /dev/null +++ b/rag/parent-delivery/live/win32/cursor/cli/audience.json @@ -0,0 +1,520 @@ +{ + "schema": "peertable.parent-live-case.v1", + "case_id": "win32/cursor/cli/audience", + "status": "passed", + "reasons": [], + "native": true, + "os": "win32", + "harness": "cursor", + "surface": "cli", + "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8", + "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", + "package_version": "0.8.63", + "client_version": "0.8.63", + "package_tarball_sha256": "55f7f8789b24f4a316b9b67414b5e96a63a2422110085215dcb5b485c58e6fc6", + "harness_version": "2026.09.26-dd393fe", + "node_version": "v24.20.0", + "parent_session": "1ee065f6-5b2f-4fd3-9bf5-3409761385c1", + "parent_process": { + "parent": 41816, + "pid": 59872, + "executable": "C:\\Users\\kite_\\AppData\\Local\\cursor-agent\\versions\\2026.09.26-dd393fe\\node.exe", + "command": "\"C:\\Users\\kite_\\AppData\\Local\\cursor-agent\\versions\\2026.09.26-dd393fe\\node.exe\" C:\\Users\\kite_\\AppData\\Local\\cursor-agent\\versions\\2026.09.26-dd393fe\\index.js --model auto --trust --approve-mcps --force", + "started": "2026-09-28T18:04:34.0854670Z" + }, + "endpoint_id": "bd2b10ce-400d-4181-ba49-72e3edc6e4a8", + "room": "qa-cg", + "owner_input_required": false, + "isolation": { + "fixture": "dedicated room server + token file, dedicated private QA dir", + "home": "normal HOME/auth (no fake HOME)", + "harness_config": "project .cursor/.grok fixture hooks+MCP (no global connect)", + "install": "npm pack tarball installed with npm install --prefix; product room/client/hook run from the installed package", + "model": "Auto", + "permission": "--force --approve-mcps --trust" + }, + "observations": [ + { + "kind": "parent_join", + "parent_session": "1ee065f6-5b2f-4fd3-9bf5-3409761385c1", + "turn_id": "7b004158-d12c-4fb4-9c1a-e9ce6a938114", + "tool_use_id": "a597ae38-e2da-4f00-b400-d0ed248aac3a", + "endpoint_id": "bd2b10ce-400d-4181-ba49-72e3edc6e4a8", + "caller_owner": { + "parent": 41816, + "pid": 59872, + "executable": "C:\\Users\\kite_\\AppData\\Local\\cursor-agent\\versions\\2026.09.26-dd393fe\\node.exe", + "command": "\"C:\\Users\\kite_\\AppData\\Local\\cursor-agent\\versions\\2026.09.26-dd393fe\\node.exe\" C:\\Users\\kite_\\AppData\\Local\\cursor-agent\\versions\\2026.09.26-dd393fe\\index.js --model auto --trust --approve-mcps --force", + "started": "2026-09-28T18:04:34.0854670Z" + }, + "call_generation": "df8f9dfe-0e16-434d-a65f-773806c1cd6e" + }, + { + "kind": "native_background_task_armed", + "parent_session": "1ee065f6-5b2f-4fd3-9bf5-3409761385c1", + "turn_id": "7b004158-d12c-4fb4-9c1a-e9ce6a938114", + "task_id": "326765", + "task_pid": 71008, + "task_process_started": "2026-09-28T18:09:11.9147650Z", + "waiter_pid": 50576, + "waiter_started": "2026-09-28T18:09:12.4236450Z", + "runtime": "armed", + "task_id_transitions": [ + "POSITIVE A seq 4 task 326762 -> 326763\r", + "POSITIVE D seq 5 task 326763 -> 326764\r", + "NEGATIVE X seq 6 (expect no delivery to bell)\r", + "POSITIVE M seq 7 task 326764 -> 326765\r", + "NEGATIVE S seq 8 (expect no delivery to bell)\r", + "STATE 326765\r" + ] + }, + { + "kind": "delivery_in_parent_conversation", + "label": "dm", + "parent_session": "1ee065f6-5b2f-4fd3-9bf5-3409761385c1", + "turn_id": "891fe515-2eb3-4cc6-b313-aaa65d99edcd", + "seq": 5, + "delivery_id": "14e09a1a-371a-4e70-b4e2-d095075711f6", + "entry": { + "type": "native_task_output_file", + "task_id": "326763" + }, + "boundary": { + "source": "cursor native background task output terminals/326763.txt", + "raw_body_equal": true + } + }, + { + "kind": "parent_reply_after_delivery", + "label": "dm", + "parent_session": "1ee065f6-5b2f-4fd3-9bf5-3409761385c1", + "turn_id": "891fe515-2eb3-4cc6-b313-aaa65d99edcd", + "seq": 5, + "reply_excerpt": "受信:\n[Peertable room=qa-cg from=worker to=\"bell\" seq=5]\n本文: QA-D-WCUR: 親だけ宛のDMです。符号 DM-WCUR\n[配送ID=14e09a1a-371a-4e70-b4e2-d095075711f6 digest=19fa1a6eb55c6c6d9580d21a0af7a529f292488d968b0f1872a4e9fba06" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "multiple", + "parent_session": "1ee065f6-5b2f-4fd3-9bf5-3409761385c1", + "turn_id": "e0307272-990c-488c-9b2e-34cae2e2f2da", + "seq": 7, + "delivery_id": "b7dcbcff-3c74-46ea-8b91-c94cb775ea73", + "entry": { + "type": "native_task_output_file", + "task_id": "326764" + }, + "boundary": { + "source": "cursor native background task output terminals/326764.txt", + "raw_body_equal": true + } + }, + { + "kind": "parent_reply_after_delivery", + "label": "multiple", + "parent_session": "1ee065f6-5b2f-4fd3-9bf5-3409761385c1", + "turn_id": "e0307272-990c-488c-9b2e-34cae2e2f2da", + "seq": 7, + "reply_excerpt": "受信:\n[Peertable room=qa-cg from=worker to=[\"bell\",\"peer2\"] seq=7]\n本文: QA-M-WCUR: 親とpeer2宛の複数宛です。符号 MULTI-WCUR\n[配送ID=b7dcbcff-3c74-46ea-8b91-c94cb775ea73 digest=eda5f6301891ca3eee5259a57fcfbe70c81a435b7" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "all", + "parent_session": "1ee065f6-5b2f-4fd3-9bf5-3409761385c1", + "turn_id": "56b6e31b-0f5e-4793-b3c5-b1d19715f552", + "seq": 4, + "delivery_id": "68c98eb6-2391-4157-8fee-65841bc8a514", + "entry": { + "type": "native_task_output_file", + "task_id": "326762" + }, + "boundary": { + "source": "cursor native background task output terminals/326762.txt", + "raw_body_equal": true + } + }, + { + "kind": "parent_reply_after_delivery", + "label": "all", + "parent_session": "1ee065f6-5b2f-4fd3-9bf5-3409761385c1", + "turn_id": "56b6e31b-0f5e-4793-b3c5-b1d19715f552", + "seq": 4, + "reply_excerpt": "受信:\n[Peertable room=qa-cg from=worker to=\"all\" seq=4]\n本文: QA-A-WCUR: 全員宛の確認です。符号 ALL-WCUR\n[配送ID=68c98eb6-2391-4157-8fee-65841bc8a514 digest=14b389913a7a9feef3cca91bbbdf4cc9d2091a7150f037c0dc5225c85d49" + } + ], + "receipts": [ + { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "cursor_native_background_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + { + "seq": 7, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "cursor_native_background_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "cursor_native_background_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + } + ], + "body_checks": [ + { + "label": "dm", + "original": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-WCUR: 親だけ宛のDMです。符号 DM-WCUR" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-WCUR: 親だけ宛のDMです。符号 DM-WCUR" + } + }, + { + "label": "multiple", + "original": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-WCUR: 親とpeer2宛の複数宛です。符号 MULTI-WCUR" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-WCUR: 親とpeer2宛の複数宛です。符号 MULTI-WCUR" + } + }, + { + "label": "all", + "original": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-WCUR: 全員宛の確認です。符号 ALL-WCUR" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-WCUR: 全員宛の確認です。符号 ALL-WCUR" + } + } + ], + "checks": [ + { + "status": "passed", + "reasons": [], + "label": "dm", + "recipient": "bell", + "original": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-WCUR: 親だけ宛のDMです。符号 DM-WCUR" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-WCUR: 親だけ宛のDMです。符号 DM-WCUR" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-WCUR: 親だけ宛のDMです。符号 DM-WCUR", + "delivery_id": "14e09a1a-371a-4e70-b4e2-d095075711f6", + "digest": "19fa1a6eb55c6c6d9580d21a0af7a529f292488d968b0f1872a4e9fba06181e4", + "preview": false, + "order": 11, + "session": "1ee065f6-5b2f-4fd3-9bf5-3409761385c1", + "turn_id": "891fe515-2eb3-4cc6-b313-aaa65d99edcd", + "boundary": { + "source": "cursor native background task output terminals/326763.txt", + "raw_body_equal": true + }, + "entry": { + "type": "native_task_output_file", + "task_id": "326763" + } + }, + "reply": { + "session": "1ee065f6-5b2f-4fd3-9bf5-3409761385c1", + "text": "受信:\n[Peertable room=qa-cg from=worker to=\"bell\" seq=5]\n本文: QA-D-WCUR: 親だけ宛のDMです。符号 DM-WCUR\n[配送ID=14e09a1a-371a-4e70-b4e2-d095075711f6 digest=19fa1a6eb55c6c6d9580d21a0af7a529f292488d968b0f1872a4e9fba06181e4]\n\n今回は私(`bell`)だけに宛てたDMだったよ。本文は背景待機の完了出力に全文そろっていて(`complete: true`)、それをそのまま貼った。`parent_read`でも回収したけど、本文の追加ページはなかった(`page: null`)。返ってきた入力で受信待機を張り直したから、また待つね。", + "order": 14, + "turn_id": "891fe515-2eb3-4cc6-b313-aaa65d99edcd", + "entry_uuid": null + }, + "spool": { + "delivery_id": "14e09a1a-371a-4e70-b4e2-d095075711f6", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "cursor_native_background_output" + }, + "receipt": { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "cursor_native_background_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + "tui_full_body_present": true + }, + { + "status": "passed", + "reasons": [], + "label": "multiple", + "recipient": "bell", + "original": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-WCUR: 親とpeer2宛の複数宛です。符号 MULTI-WCUR" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-WCUR: 親とpeer2宛の複数宛です。符号 MULTI-WCUR" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-WCUR: 親とpeer2宛の複数宛です。符号 MULTI-WCUR", + "delivery_id": "b7dcbcff-3c74-46ea-8b91-c94cb775ea73", + "digest": "eda5f6301891ca3eee5259a57fcfbe70c81a435b7bf6f9eb17abdf4812003ac4", + "preview": false, + "order": 16, + "session": "1ee065f6-5b2f-4fd3-9bf5-3409761385c1", + "turn_id": "e0307272-990c-488c-9b2e-34cae2e2f2da", + "boundary": { + "source": "cursor native background task output terminals/326764.txt", + "raw_body_equal": true + }, + "entry": { + "type": "native_task_output_file", + "task_id": "326764" + } + }, + "reply": { + "session": "1ee065f6-5b2f-4fd3-9bf5-3409761385c1", + "text": "受信:\n[Peertable room=qa-cg from=worker to=[\"bell\",\"peer2\"] seq=7]\n本文: QA-M-WCUR: 親とpeer2宛の複数宛です。符号 MULTI-WCUR\n[配送ID=b7dcbcff-3c74-46ea-8b91-c94cb775ea73 digest=eda5f6301891ca3eee5259a57fcfbe70c81a435b7bf6f9eb17abdf4812003ac4]\n\n今回は私(`bell`)と`peer2`の二人宛のメッセージだったよ。本文は背景待機の完了出力に全文そろっていて(`complete: true`)、それをそのまま貼った。`parent_read`でも回収したけど、本文の追加ページはなかった(`page: null`)。返ってきた入力で受信待機を張り直したから、また待つね。", + "order": 19, + "turn_id": "e0307272-990c-488c-9b2e-34cae2e2f2da", + "entry_uuid": null + }, + "spool": { + "delivery_id": "b7dcbcff-3c74-46ea-8b91-c94cb775ea73", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "cursor_native_background_output" + }, + "receipt": { + "seq": 7, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "cursor_native_background_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + "tui_full_body_present": true + }, + { + "status": "passed", + "reasons": [], + "label": "all", + "recipient": "bell", + "original": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-WCUR: 全員宛の確認です。符号 ALL-WCUR" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-WCUR: 全員宛の確認です。符号 ALL-WCUR" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-WCUR: 全員宛の確認です。符号 ALL-WCUR", + "delivery_id": "68c98eb6-2391-4157-8fee-65841bc8a514", + "digest": "14b389913a7a9feef3cca91bbbdf4cc9d2091a7150f037c0dc5225c85d49d827", + "preview": false, + "order": 6, + "session": "1ee065f6-5b2f-4fd3-9bf5-3409761385c1", + "turn_id": "56b6e31b-0f5e-4793-b3c5-b1d19715f552", + "boundary": { + "source": "cursor native background task output terminals/326762.txt", + "raw_body_equal": true + }, + "entry": { + "type": "native_task_output_file", + "task_id": "326762" + } + }, + "reply": { + "session": "1ee065f6-5b2f-4fd3-9bf5-3409761385c1", + "text": "受信:\n[Peertable room=qa-cg from=worker to=\"all\" seq=4]\n本文: QA-A-WCUR: 全員宛の確認です。符号 ALL-WCUR\n[配送ID=68c98eb6-2391-4157-8fee-65841bc8a514 digest=14b389913a7a9feef3cca91bbbdf4cc9d2091a7150f037c0dc5225c85d49d827]\n\n上の本文は、背景待機の完了出力に全文そろって入っていたもの(`complete: true`)をそのまま貼ったよ。`parent_read`でも回収したけど、本文の追加ページは返ってこなくて(`page: null`)、次の待機用の入力だけが返ってきた。その入力で受信待機を張り直したから、また待つね。", + "order": 9, + "turn_id": "56b6e31b-0f5e-4793-b3c5-b1d19715f552", + "entry_uuid": null + }, + "spool": { + "delivery_id": "68c98eb6-2391-4157-8fee-65841bc8a514", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "cursor_native_background_output" + }, + "receipt": { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "cursor_native_background_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + "tui_full_body_present": true + } + ], + "negative_controls": [ + { + "condition": "other_to_other_dm(worker->peer2)", + "seq": 6, + "room_delivery_keys": [ + "peer2" + ], + "bell_in_room_deliveries": false, + "in_bell_spool": false, + "received_in_harness_record": 0, + "reply_mentions": 0 + }, + { + "condition": "parent_self_post(bell->all)", + "seq": 8, + "room_delivery_keys": [ + "worker", + "peer2" + ], + "bell_in_room_deliveries": false, + "in_bell_spool": false, + "received_in_harness_record": 0, + "reply_mentions": 0 + } + ], + "negative_controls_ok": true, + "tui_full_body_present": { + "dm": true, + "multiple": true, + "all": true + }, + "native_task_records": [ + { + "task_id": "326762", + "pid": "31952", + "status": "succeeded", + "started_at": "2026-09-28T18:05:26.038Z" + }, + { + "task_id": "326763", + "pid": "24628", + "status": "succeeded", + "started_at": "2026-09-28T18:06:30.596Z" + }, + { + "task_id": "326764", + "pid": "28540", + "status": "succeeded", + "started_at": "2026-09-28T18:07:36.964Z" + }, + { + "task_id": "326765", + "pid": "71008", + "status": "running", + "started_at": "2026-09-28T18:09:11.911Z" + } + ], + "hook_events": { + "total": 26, + "nonzero_exit": 0, + "stdin_prefix_hex": [ + "efbbbf7b" + ] + }, + "task_output_reader": "model Read of the Cursor-saved terminals/.txt (native task output file) then parent_read", + "evidence_files": { + "state": "/private/tmp/claude-501/qa-cursor-grok/f-win-cursor/final-state.json", + "hook_events": "/private/tmp/claude-501/qa-cursor-grok/f-win-cursor/hook-events.jsonl", + "tui": "/private/tmp/claude-501/qa-cursor-grok/f-win-cursor/tui-scrollback.txt" + } +} \ No newline at end of file diff --git a/rag/parent-delivery/live/win32/grok/cli/audience.json b/rag/parent-delivery/live/win32/grok/cli/audience.json new file mode 100644 index 00000000..538cb1f5 --- /dev/null +++ b/rag/parent-delivery/live/win32/grok/cli/audience.json @@ -0,0 +1,497 @@ +{ + "schema": "peertable.parent-live-case.v1", + "case_id": "win32/grok/cli/audience", + "status": "passed", + "reasons": [], + "native": true, + "os": "win32", + "harness": "grok", + "surface": "cli", + "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8", + "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", + "package_version": "0.8.63", + "client_version": "0.8.63", + "package_tarball_sha256": "55f7f8789b24f4a316b9b67414b5e96a63a2422110085215dcb5b485c58e6fc6", + "harness_version": "1.0.41 (4220f3b224a6)", + "node_version": "v24.20.0", + "parent_session": "01a0e935-f345-7a80-b693-8a1bda596b5c", + "parent_process": { + "pid": 64488, + "command": "\"C:\\Users\\kite_\\.grok\\bin\\grok.exe\" --always-approve", + "executable": "C:\\Users\\kite_\\.grok\\bin\\grok.exe", + "started": "2026-09-28T18:10:19.6809810Z", + "parent": 15664 + }, + "endpoint_id": "3356f5ef-be0e-4b46-b0c4-2551c43314c9", + "room": "qa-cg", + "owner_input_required": false, + "isolation": { + "fixture": "dedicated room server + token file, dedicated private QA dir", + "home": "normal HOME/auth (no fake HOME)", + "harness_config": "project .cursor/.grok fixture hooks+MCP (no global connect)", + "install": "npm pack tarball installed with npm install --prefix; product room/client/hook run from the installed package", + "model": "Grok 4.6 (high)", + "permission": "--always-approve + /hooks-trust" + }, + "observations": [ + { + "kind": "parent_join", + "parent_session": "01a0e935-f345-7a80-b693-8a1bda596b5c", + "turn_id": "f9f0a8b4-2e1e-4a14-9f22-484998ee0b47", + "tool_use_id": "call-dcefbf56-c224-4bd6-9661-ca9292733345-2", + "endpoint_id": "3356f5ef-be0e-4b46-b0c4-2551c43314c9", + "caller_owner": { + "pid": 64488, + "command": "\"C:\\Users\\kite_\\.grok\\bin\\grok.exe\" --always-approve", + "executable": "C:\\Users\\kite_\\.grok\\bin\\grok.exe", + "started": "2026-09-28T18:10:19.6809810Z", + "parent": 15664 + }, + "call_generation": "08429519-b214-4aef-a239-b4c9052e30aa" + }, + { + "kind": "native_background_task_armed", + "parent_session": "01a0e935-f345-7a80-b693-8a1bda596b5c", + "turn_id": "f9f0a8b4-2e1e-4a14-9f22-484998ee0b47", + "task_id": "01a0e93e-d2e9-7403-92cf-c772fd4e9eee", + "task_pid": 48456, + "task_process_started": "2026-09-28T18:20:03.6013190Z", + "waiter_pid": 63816, + "waiter_started": "2026-09-28T18:20:04.1634290Z", + "runtime": "armed", + "task_id_transitions": [ + "POSITIVE A seq 4 task -> 01a0e93c-3506-7531-8f5d-fa32401e2aef\r", + "POSITIVE D seq 5 task 01a0e93c-3506-7531-8f5d-fa32401e2aef -> 01a0e93d-5984-71c0-bec7-6bba006f2af1\r", + "NEGATIVE X seq 6 (expect no delivery to bell)\r", + "POSITIVE M seq 7 task 01a0e93d-5984-71c0-bec7-6bba006f2af1 -> 01a0e93e-d2e9-7403-92cf-c772fd4e9eee\r", + "NEGATIVE S seq 8 (expect no delivery to bell)\r", + "STATE 01a0e93e-d2e9-7403-92cf-c772fd4e9eee\r" + ] + }, + { + "kind": "delivery_in_parent_conversation", + "label": "dm", + "parent_session": "01a0e935-f345-7a80-b693-8a1bda596b5c", + "turn_id": "task-completed-01a0e93c-3506-7531-8f5d-fa32401e2aef", + "seq": 5, + "delivery_id": "49e65c2a-2d06-48b4-a9da-c3c8b0064971", + "entry": { + "type": "tool_call_update", + "id": "call-ec60c0e0-edf5-4651-a62a-4e74499f549d-17" + }, + "boundary": { + "source": "grok parent_read MCP result (transcript updates.jsonl)", + "raw_body_equal": true + } + }, + { + "kind": "parent_reply_after_delivery", + "label": "dm", + "parent_session": "01a0e935-f345-7a80-b693-8a1bda596b5c", + "turn_id": "task-completed-01a0e93c-3506-7531-8f5d-fa32401e2aef", + "seq": 5, + "reply_excerpt": "受信: QA-D-WGRK: 親だけ宛のDMです。符号 DM-WGRK" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "multiple", + "parent_session": "01a0e935-f345-7a80-b693-8a1bda596b5c", + "turn_id": "task-completed-01a0e93d-5984-71c0-bec7-6bba006f2af1", + "seq": 7, + "delivery_id": "2043fb8a-1d06-46e1-9f76-9b80aa3dad11", + "entry": { + "type": "tool_call_update", + "id": "call-b8e8cd92-a9cb-4e50-a902-b1d6ba4747aa-20" + }, + "boundary": { + "source": "grok parent_read MCP result (transcript updates.jsonl)", + "raw_body_equal": true + } + }, + { + "kind": "parent_reply_after_delivery", + "label": "multiple", + "parent_session": "01a0e935-f345-7a80-b693-8a1bda596b5c", + "turn_id": "task-completed-01a0e93d-5984-71c0-bec7-6bba006f2af1", + "seq": 7, + "reply_excerpt": "受信: QA-M-WGRK: 親とpeer2宛の複数宛です。符号 MULTI-WGRK" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "all", + "parent_session": "01a0e935-f345-7a80-b693-8a1bda596b5c", + "turn_id": "task-completed-01a0e93b-2628-7ab2-92ef-79dc9773b0eb", + "seq": 4, + "delivery_id": "ab05a866-70d0-4580-ac94-a710f6cfb8cf", + "entry": { + "type": "tool_call_update", + "id": "call-d3119ab5-0032-4850-a613-de7ff450f80d-14" + }, + "boundary": { + "source": "grok parent_read MCP result (transcript updates.jsonl)", + "raw_body_equal": true + } + }, + { + "kind": "parent_reply_after_delivery", + "label": "all", + "parent_session": "01a0e935-f345-7a80-b693-8a1bda596b5c", + "turn_id": "task-completed-01a0e93b-2628-7ab2-92ef-79dc9773b0eb", + "seq": 4, + "reply_excerpt": "受信: QA-A-WGRK: 全員宛の確認です。符号 ALL-WGRK" + } + ], + "receipts": [ + { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "parent_read_complete", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + { + "seq": 7, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "parent_read_complete", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "parent_read_complete", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + } + ], + "body_checks": [ + { + "label": "dm", + "original": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-WGRK: 親だけ宛のDMです。符号 DM-WGRK" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-WGRK: 親だけ宛のDMです。符号 DM-WGRK" + } + }, + { + "label": "multiple", + "original": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-WGRK: 親とpeer2宛の複数宛です。符号 MULTI-WGRK" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-WGRK: 親とpeer2宛の複数宛です。符号 MULTI-WGRK" + } + }, + { + "label": "all", + "original": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-WGRK: 全員宛の確認です。符号 ALL-WGRK" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-WGRK: 全員宛の確認です。符号 ALL-WGRK" + } + } + ], + "checks": [ + { + "status": "passed", + "reasons": [], + "label": "dm", + "recipient": "bell", + "original": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-WGRK: 親だけ宛のDMです。符号 DM-WGRK" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-WGRK: 親だけ宛のDMです。符号 DM-WGRK" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "qa-cg", + "from": "worker", + "to": "bell", + "seq": 5, + "body": "QA-D-WGRK: 親だけ宛のDMです。符号 DM-WGRK", + "delivery_id": "49e65c2a-2d06-48b4-a9da-c3c8b0064971", + "digest": "b2e0370490bb15383848d10707f2132f7aef49fbe2b77ba7589b2436b38803d5", + "order": 156, + "session": "01a0e935-f345-7a80-b693-8a1bda596b5c", + "turn_id": "task-completed-01a0e93c-3506-7531-8f5d-fa32401e2aef", + "boundary": { + "source": "grok parent_read MCP result (transcript updates.jsonl)", + "raw_body_equal": true + }, + "entry": { + "type": "tool_call_update", + "id": "call-ec60c0e0-edf5-4651-a62a-4e74499f549d-17" + } + }, + "reply": { + "session": "01a0e935-f345-7a80-b693-8a1bda596b5c", + "text": "受信: QA-D-WGRK: 親だけ宛のDMです。符号 DM-WGRK", + "order": 166, + "turn_id": "task-completed-01a0e93c-3506-7531-8f5d-fa32401e2aef", + "entry_uuid": null + }, + "spool": { + "delivery_id": "49e65c2a-2d06-48b4-a9da-c3c8b0064971", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "parent_read_complete" + }, + "receipt": { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "parent_read_complete", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + "tui_full_body_present": false + }, + { + "status": "passed", + "reasons": [], + "label": "multiple", + "recipient": "bell", + "original": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-WGRK: 親とpeer2宛の複数宛です。符号 MULTI-WGRK" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-WGRK: 親とpeer2宛の複数宛です。符号 MULTI-WGRK" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "qa-cg", + "from": "worker", + "to": [ + "bell", + "peer2" + ], + "seq": 7, + "body": "QA-M-WGRK: 親とpeer2宛の複数宛です。符号 MULTI-WGRK", + "delivery_id": "2043fb8a-1d06-46e1-9f76-9b80aa3dad11", + "digest": "4d42d416c2ad200220220cc30186aa129fff75ee35024122f0b6feac45ded05b", + "order": 181, + "session": "01a0e935-f345-7a80-b693-8a1bda596b5c", + "turn_id": "task-completed-01a0e93d-5984-71c0-bec7-6bba006f2af1", + "boundary": { + "source": "grok parent_read MCP result (transcript updates.jsonl)", + "raw_body_equal": true + }, + "entry": { + "type": "tool_call_update", + "id": "call-b8e8cd92-a9cb-4e50-a902-b1d6ba4747aa-20" + } + }, + "reply": { + "session": "01a0e935-f345-7a80-b693-8a1bda596b5c", + "text": "受信: QA-M-WGRK: 親とpeer2宛の複数宛です。符号 MULTI-WGRK", + "order": 191, + "turn_id": "task-completed-01a0e93d-5984-71c0-bec7-6bba006f2af1", + "entry_uuid": null + }, + "spool": { + "delivery_id": "2043fb8a-1d06-46e1-9f76-9b80aa3dad11", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "parent_read_complete" + }, + "receipt": { + "seq": 7, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "parent_read_complete", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + "tui_full_body_present": false + }, + { + "status": "passed", + "reasons": [], + "label": "all", + "recipient": "bell", + "original": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-WGRK: 全員宛の確認です。符号 ALL-WGRK" + }, + "received": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-WGRK: 全員宛の確認です。符号 ALL-WGRK" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "qa-cg", + "from": "worker", + "to": "all", + "seq": 4, + "body": "QA-A-WGRK: 全員宛の確認です。符号 ALL-WGRK", + "delivery_id": "ab05a866-70d0-4580-ac94-a710f6cfb8cf", + "digest": "daecdf0b5e298c07ee37c1c728bce7ad8eedf2cfe9446e6a5086f551ab1a31a7", + "order": 131, + "session": "01a0e935-f345-7a80-b693-8a1bda596b5c", + "turn_id": "task-completed-01a0e93b-2628-7ab2-92ef-79dc9773b0eb", + "boundary": { + "source": "grok parent_read MCP result (transcript updates.jsonl)", + "raw_body_equal": true + }, + "entry": { + "type": "tool_call_update", + "id": "call-d3119ab5-0032-4850-a613-de7ff450f80d-14" + } + }, + "reply": { + "session": "01a0e935-f345-7a80-b693-8a1bda596b5c", + "text": "受信: QA-A-WGRK: 全員宛の確認です。符号 ALL-WGRK", + "order": 141, + "turn_id": "task-completed-01a0e93b-2628-7ab2-92ef-79dc9773b0eb", + "entry_uuid": null + }, + "spool": { + "delivery_id": "ab05a866-70d0-4580-ac94-a710f6cfb8cf", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "parent_read_complete" + }, + "receipt": { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "parent_read_complete", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + "tui_full_body_present": false + } + ], + "negative_controls": [ + { + "condition": "other_to_other_dm(worker->peer2)", + "seq": 6, + "room_delivery_keys": [ + "peer2" + ], + "bell_in_room_deliveries": false, + "in_bell_spool": false, + "received_in_harness_record": 0, + "reply_mentions": 0 + }, + { + "condition": "parent_self_post(bell->all)", + "seq": 8, + "room_delivery_keys": [ + "worker", + "peer2" + ], + "bell_in_room_deliveries": false, + "in_bell_spool": false, + "received_in_harness_record": 0, + "reply_mentions": 0 + } + ], + "negative_controls_ok": true, + "tui_full_body_present": { + "dm": false, + "multiple": false, + "all": false + }, + "native_task_records": [ + { + "background_started": 7, + "background_completed": 6 + } + ], + "hook_events": { + "total": 44, + "nonzero_exit": 0, + "stdin_prefix_hex": [ + "7b22686f" + ] + }, + "task_output_reader": "get_command_or_subagent_output(task_id, no timeout_ms) then parent_read", + "evidence_files": { + "state": "/private/tmp/claude-501/qa-cursor-grok/f-win-grok/final-state.json", + "hook_events": "/private/tmp/claude-501/qa-cursor-grok/f-win-grok/hook-events.jsonl", + "tui": "/private/tmp/claude-501/qa-cursor-grok/f-win-grok/tui-scrollback.txt" + } +} \ No newline at end of file diff --git a/rag/parent-delivery/product-acceptance.json b/rag/parent-delivery/product-acceptance.json index 1f6e357d..c8d7a09d 100644 --- a/rag/parent-delivery/product-acceptance.json +++ b/rag/parent-delivery/product-acceptance.json @@ -1,4 +1,258 @@ { "schema": "peertable.parent-product-acceptance.v1", - "records": [] + "records": [ + { + "id": "darwin/cursor/cli/audience", + "status": "passed", + "kind": "product_live", + "native": true, + "harness_version": "2026.09.26-dd393fe", + "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8", + "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", + "package_version": "0.8.63", + "parent_session": "d70e9c86-b865-4732-b6a0-04b6af92f7a5", + "evidence_file": "rag/parent-delivery/live/darwin/cursor/cli/audience.json", + "body_equal": true, + "owner_input_required": false, + "receipt_evidence": "5:bell:r1,7:bell:r1,4:bell:r1", + "observations": [ + "parent_join", + "native_background_task_armed", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery" + ], + "audiences": { + "dm": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "multiple": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "all": { + "count": 1, + "body_equal": true, + "status": "passed" + } + } + }, + { + "id": "darwin/grok/cli/audience", + "status": "passed", + "kind": "product_live", + "native": true, + "harness_version": "1.0.41 (4220f3b224a6)", + "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8", + "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", + "package_version": "0.8.63", + "parent_session": "01a0e926-e47b-7a01-b2ca-cd3b02d99d55", + "evidence_file": "rag/parent-delivery/live/darwin/grok/cli/audience.json", + "body_equal": true, + "owner_input_required": false, + "receipt_evidence": "5:bell:r1,7:bell:r1,4:bell:r1", + "observations": [ + "parent_join", + "native_background_task_armed", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery" + ], + "audiences": { + "dm": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "multiple": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "all": { + "count": 1, + "body_equal": true, + "status": "passed" + } + } + }, + { + "id": "linux/cursor/cli/audience", + "status": "passed", + "kind": "product_live", + "native": true, + "harness_version": "2026.09.26-dd393fe", + "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8", + "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", + "package_version": "0.8.63", + "parent_session": "78326524-689e-4921-b983-4cb13b717a36", + "evidence_file": "rag/parent-delivery/live/linux/cursor/cli/audience.json", + "body_equal": true, + "owner_input_required": false, + "receipt_evidence": "5:bell:r1,7:bell:r1,4:bell:r1", + "observations": [ + "parent_join", + "native_background_task_armed", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery" + ], + "audiences": { + "dm": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "multiple": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "all": { + "count": 1, + "body_equal": true, + "status": "passed" + } + } + }, + { + "id": "linux/grok/cli/audience", + "status": "passed", + "kind": "product_live", + "native": true, + "harness_version": "1.0.41 (4220f3b224a6)", + "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8", + "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", + "package_version": "0.8.63", + "parent_session": "01a0e92c-b15c-7b73-a7cb-dea3fa2e1072", + "evidence_file": "rag/parent-delivery/live/linux/grok/cli/audience.json", + "body_equal": true, + "owner_input_required": false, + "receipt_evidence": "5:bell:r1,7:bell:r1,4:bell:r1", + "observations": [ + "parent_join", + "native_background_task_armed", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery" + ], + "audiences": { + "dm": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "multiple": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "all": { + "count": 1, + "body_equal": true, + "status": "passed" + } + } + }, + { + "id": "win32/cursor/cli/audience", + "status": "passed", + "kind": "product_live", + "native": true, + "harness_version": "2026.09.26-dd393fe", + "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8", + "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", + "package_version": "0.8.63", + "parent_session": "1ee065f6-5b2f-4fd3-9bf5-3409761385c1", + "evidence_file": "rag/parent-delivery/live/win32/cursor/cli/audience.json", + "body_equal": true, + "owner_input_required": false, + "receipt_evidence": "5:bell:r1,7:bell:r1,4:bell:r1", + "observations": [ + "parent_join", + "native_background_task_armed", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery" + ], + "audiences": { + "dm": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "multiple": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "all": { + "count": 1, + "body_equal": true, + "status": "passed" + } + } + }, + { + "id": "win32/grok/cli/audience", + "status": "passed", + "kind": "product_live", + "native": true, + "harness_version": "1.0.41 (4220f3b224a6)", + "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8", + "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", + "package_version": "0.8.63", + "parent_session": "01a0e935-f345-7a80-b693-8a1bda596b5c", + "evidence_file": "rag/parent-delivery/live/win32/grok/cli/audience.json", + "body_equal": true, + "owner_input_required": false, + "receipt_evidence": "5:bell:r1,7:bell:r1,4:bell:r1", + "observations": [ + "parent_join", + "native_background_task_armed", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery" + ], + "audiences": { + "dm": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "multiple": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "all": { + "count": 1, + "body_equal": true, + "status": "passed" + } + } + } + ], + "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8" } diff --git a/rag/parent-delivery/receivers.md b/rag/parent-delivery/receivers.md index d1f45e4e..6e398536 100644 --- a/rag/parent-delivery/receivers.md +++ b/rag/parent-delivery/receivers.md @@ -48,7 +48,9 @@ Cursor/Grokの6 CLIは旧snapshotで5件失敗、Windows Grokの無改造試験 親のCodex idle長文試験でroomのUTF-8断片decodeによる原文破損が再現された。`readBody`をNodeのstream decoderへ修理し、[HTTP UTF-8再現](../../experiments/http-utf8-body-repro.mjs)で40,097 UTF-16文字の日本語・emojiの途中を5箇所で分割する。POST応答、GET messages、保存logの完全一致をfocused検証する。修理前のnative idle長文試験は失敗証拠として親が保管し、成功へ変更しない。 -公開前gateは`npm run verify:parent-delivery`で[実機manifest](product-acceptance.json)を検査する。3 OS・7実行面・25scenarioの525件を要求し、欠落/skip/fixture・証拠file欠落・source digest/version不一致を拒否する。現manifestは空で、公開gateは未達。gateは会話/原文/receipt等の共通証拠を機械照合し、scenario固有の実観測の十分性は親の受入監査でも確認する。実装チェックポイントと全製品受入完了を分ける。 +公開前gateは`npm run verify:parent-delivery`で[実機manifest](product-acceptance.json)を検査する。3 OS・7実行面・25scenarioの525件を要求し、欠落/skip/fixture・証拠file欠落・source digest/version不一致を拒否する。完了件数と残りはmanifestから算出する。gateは会話/原文/receipt等の共通証拠を機械照合し、scenario固有の実観測の十分性は親の受入監査でも確認する。実装チェックポイントと全製品受入完了を分ける。 + +固定した最終製品sourceで取得したCursor/Grok通常CLIの3 OS audienceは、[親の独立監査](cursor-grok-formal-review.json)でnative task出力または公式`parent_read`結果、room本文、実会話のturn、後続assistant返答を照合してmanifestへ受け入れた。他者間DMと親自身の発言は非配送だった。Linux Grokの複数宛とWindows GrokのTUI画面は取得不足であり、`tui_full_body_present:false`を残す。CLIの受信本文と返答の根拠は保存済みの公式会話記録であり、画面確認の合格とは扱わない。 ## 配布候補の確認 From c5958ac15700de0a29a4ea441e6c93aafc595379 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:44:39 +0900 Subject: [PATCH 26/72] =?UTF-8?q?Cursor=E3=81=A8Grok=E3=81=AE=E5=85=AC?= =?UTF-8?q?=E5=BC=8F=E8=83=8C=E6=99=AF=E5=87=BA=E5=8A=9B=E3=82=92=E5=AE=9F?= =?UTF-8?q?=E4=BC=9A=E8=A9=B1=E3=81=AE=E8=A6=B3=E6=B8=AC=E3=81=B8=E6=8E=A5?= =?UTF-8?q?=E7=B6=9A=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../background-harness.mjs | 228 ++++++++++++++++++ .../background-harness.test.mjs | 139 +++++++++++ 2 files changed, 367 insertions(+) create mode 100644 experiments/parent-product-acceptance/background-harness.mjs create mode 100644 experiments/parent-product-acceptance/background-harness.test.mjs diff --git a/experiments/parent-product-acceptance/background-harness.mjs b/experiments/parent-product-acceptance/background-harness.mjs new file mode 100644 index 00000000..57642312 --- /dev/null +++ b/experiments/parent-product-acceptance/background-harness.mjs @@ -0,0 +1,228 @@ +// 通常CLIの公式記録を読む。起動・認証・hook設定は操作adapterが所有する。 +import { readFileSync } from 'node:fs' +import { isDeepStrictEqual } from 'node:util' +import { parseDelivered } from './evidence.mjs' + +const fail = (code, message) => { throw Object.assign(new Error(message), { code }) } +const decode = bytes => { const text = Buffer.isBuffer(bytes) ? bytes.toString('utf8') : bytes; return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text } +const json = (text, source) => { try { return JSON.parse(decode(text)) } catch { fail('ACCEPTANCE_TRANSCRIPT_CORRUPT', `${source} のJSONが破損しています`) } } + +// 元Bufferは変更しない。改行済みの行だけを確定とし、未確定末尾は観測から外す。 +export function readBackgroundJsonl(bytes, file = 'native transcript') { + const lines = decode(bytes).split('\n'), tail = lines.pop() + return { rows: lines.map((line, order) => ({ order, row: json(line, `${file}:${order + 1}`) })), pending_tail: tail.length } +} +const checkSession = (expected, actual, source) => { + if (actual != null && actual !== expected) fail('ACCEPTANCE_NATIVE_SESSION_MISMATCH', `${source} の会話IDが期待値と一致しません`) +} +const hookEvent = row => row.event ?? row.input ?? row +const eventName = event => event.hook_event_name ?? event.hookEventName +const hookName = event => event.tool_name ?? event.toolName +const hookInput = event => event.tool_input ?? event.toolInput +const hookOutput = event => event.tool_output ?? event.toolResult +const hookId = event => event.tool_use_id ?? event.toolUseId +const hookRows = (hooks, expected) => hooks.map((item, order) => { + const event = hookEvent(item) + for (const value of [event.conversation_id, event.session_id, event.sessionId]) checkSession(expected, value, `hook:${order}`) + const path = event.transcript_path ?? event.transcriptPath + if (path && !path.split(/[\\/]/u).includes(expected) && !path.endsWith(`${expected}.jsonl`)) fail('ACCEPTANCE_NATIVE_SESSION_MISMATCH', `hook:${order} のtranscript pathが別会話です`) + return { event, order, at: item.at ?? event.timestamp ?? null } +}) +const empty = ({ rows, pending_tail }) => ({ rows: rows.length, pending_tail, toolUses: [], replies: [], injections: [], deliveries: [], turns: [], queued: [], pages: [], tasks: [] }) +const rememberTurn = (seen, turn) => { if (turn && !seen.turns.includes(turn)) seen.turns.push(turn) } +const pageOf = value => value?.schema === 'peertable.parent-read-page.v1' ? value : value?.schema === 'peertable.parent-read-result.v1' ? value.page : null +const cursorValues = output => { + const outer = typeof output === 'string' ? json(output, 'Cursor tool_output') : output + const values = [outer] + for (const part of outer?.content ?? []) if (part.type === 'text') values.push(json(part.text, 'Cursor MCP text')) + if (outer?.structuredContent) values.push(outer.structuredContent) + return values +} +const addPages = (seen, values, entry) => { + // 同じMCP結果のcontent/structuredContent二重包装だけを一つにする。別tool callの再読は残す。 + const unique = [] + for (const value of values) { const page = pageOf(value); if (page && !unique.some(item => isDeepStrictEqual(item, page))) unique.push(page) } + for (const page of unique) seen.pages.push({ ...entry, page }) +} +const deliveryFromPage = (page, entry, body, source) => ({ room: page.event.room, from: page.event.from ?? page.event.message?.from, to: page.event.to_names ?? page.event.to ?? page.event.message?.to_names ?? page.event.message?.to, seq: page.event.seq, body, delivery_id: page.delivery_id, digest: page.digest, preview: false, ...entry, boundary: { source, raw_body_equal: true, encoding: 'none' } }) + +// page列は配送ごとにoffset 0からcompleteまでを一回の回収として束ねる。 +// complete後に再びoffset 0が来た場合は別受信として残し、真の重複を隠さない。 +const pageDeliveries = seen => { + const runs = new Map() + for (const entry of seen.pages) { + const page = entry.page + let run = runs.get(page.delivery_id) + if (page.offset === 0 && run) fail('ACCEPTANCE_PAGE_NATIVE_SEQUENCE', '公式pageが回収途中でoffset 0へ戻りました') + if (page.offset === 0) { run = { first: page, body: '', offset: 0 }; runs.set(page.delivery_id, run) } + if (!run || page.offset !== run.offset || page.digest !== run.first.digest || page.total_characters !== run.first.total_characters || typeof page.event?.body !== 'string') fail('ACCEPTANCE_PAGE_NATIVE_SEQUENCE', '公式pageの順序・identityが不一致です') + run.body += page.event.body; run.offset += page.event.body.length + if (page.complete !== (run.offset === page.total_characters)) fail('ACCEPTANCE_PAGE_NATIVE_FINAL', '公式pageのcompleteと本文長が一致しません') + if (page.complete) { + const related = seen.deliveries.find(item => item.delivery_id === page.delivery_id && item.native_task_id === entry.native_task_id) + if (related?.entry?.type === 'native_task_output_file' && !related.page_recovered) { related.page_recovered = true; related.native_pages = (related.native_pages ?? 0) + 1; related.page_order = entry.order } + else if (page.event.seq !== undefined) seen.deliveries.push(deliveryFromPage(run.first, entry, run.body, entry.source ?? 'grok parent_read MCP result (transcript updates.jsonl)')) + runs.delete(page.delivery_id) + } + } +} +const cursorTool = part => part.name === 'CallDynamicTool' && part.input?.namespace === 'peertable_parent' + ? { name: part.input.toolName, input: part.input.arguments } + : { name: part.name, input: part.input } +const comparableCursorInput = (name, input) => { + if (name === 'Read') return { path: input?.path ?? input?.file_path, ...(input?.offset !== undefined ? { offset: input.offset } : {}), ...(input?.limit !== undefined ? { limit: input.limit } : {}) } + if (name === 'Shell') return { command: input?.command } + if (['parent_join', 'parent_read', 'parent_leave'].includes(name)) return Object.fromEntries(Object.entries(input ?? {}).filter(([key]) => key !== 'hook_context_id')) + return input +} +const terminalOutput = (bytes, source) => { + const text = decode(bytes), match = /^---\r?\n[\s\S]*?\r?\n---\r?\n/u.exec(text) + if (!match) fail('ACCEPTANCE_NATIVE_TASK_OUTPUT_FORMAT', `${source} に公式front matterがありません`) + const rest = text.slice(match[0].length), footer = /\r?\n---\r?\nexit_code:/u.exec(rest) + const stdout = footer ? rest.slice(0, footer.index) : rest + const status = /^status: (\S+)/mu.exec(match[0])?.[1] ?? null + // 成功stdoutのJSON記録だけを読む。失敗taskの診断本文や任意コマンドのplain textは配送ではない。 + const parsed = status === 'succeeded' && stdout.startsWith('{') ? readBackgroundJsonl(stdout, source) : { rows: [], pending_tail: 0 } + return { text, status, stdout, pending_tail: parsed.pending_tail, values: parsed.rows.map(item => item.row), front: match[0] } +} + +export function parseCursorNativeTranscript({ transcript, hooks = [], expectedConversationId, readTaskFile = path => readFileSync(path), file = 'Cursor transcript' }) { + if (!expectedConversationId) fail('ACCEPTANCE_NATIVE_SESSION_REQUIRED', '期待するCursor会話IDが必要です') + const parsed = readBackgroundJsonl(transcript, file), seen = empty(parsed), events = hookRows(hooks, expectedConversationId) + const post = events.filter(({ event }) => eventName(event) === 'postToolUse') + let nextHook = 0, turn = null + const readTasks = new Set() + for (const { order, row } of parsed.rows) { + checkSession(expectedConversationId, row.conversation_id ?? row.sessionId ?? row.session_id, `${file}:${order}`) + const parts = row.message?.content ?? [] + if (row.role === 'user') turn = null + for (const part of parts) { + if (part.type !== 'tool_use') continue + const tool = cursorTool(part) + const index = post.findIndex(({ event }, i) => i >= nextHook && (hookName(event) === `MCP:${tool.name}` || hookName(event) === tool.name) && isDeepStrictEqual(comparableCursorInput(tool.name, tool.input), comparableCursorInput(tool.name, hookInput(event)))) + const event = index < 0 ? null : post[index].event + if (event) { nextHook = index + 1; turn = event.generation_id ?? null; rememberTurn(seen, turn) } + const entry = { order, session: expectedConversationId, turn_id: turn, id: event ? hookId(event) : part.id ?? null, name: tool.name, input: tool.input, raw_input: part.input, native_name: part.name, output: event ? hookOutput(event) : undefined, hook_input: event ? hookInput(event) : undefined } + seen.toolUses.push(entry) + if (!event) continue + if (!['Shell', 'Read', 'parent_join', 'parent_read', 'parent_leave'].includes(tool.name)) continue + const output = cursorValues(hookOutput(event)) + if (tool.name === 'Shell') { + const task = output[0] + if (task?.shell_id != null) seen.tasks.push({ ...entry, id: String(task.shell_id), tool_use_id: entry.id, type: 'task_backgrounded', task_id: String(task.shell_id), pid: task.pid, raw_output: task }) + } + if (tool.name === 'Read') { + const path = tool.input?.path ?? tool.input?.file_path + const taskId = /(?:^|[\\/])terminals[\\/](\d+)\.txt$/u.exec(path ?? '')?.[1] + const metadata = output[0] + if (!taskId || metadata?.file_path !== path || !Number.isSafeInteger(metadata.content_length)) continue + const rawBytes = readTaskFile(path), native = terminalOutput(rawBytes, path) + // 保存済み最終fileを、完了前に読んだ短いReadへ遡って結び付けない。 + if (metadata.content_length < native.text.length || tool.input.offset !== undefined || tool.input.limit !== undefined) continue + for (const [line, value] of native.values.entries()) { + if (value.schema !== 'peertable.parent-background-result.v1') continue + seen.tasks.push({ ...entry, id: taskId, reader_tool_use_id: entry.id, type: 'task_output_read', task_id: taskId, output_file: path, result: value, raw_bytes: rawBytes }) + const key = `${path}:${line}` + if (readTasks.has(key)) continue + readTasks.add(key) + const base = { order, session: expectedConversationId, turn_id: turn, native_task_id: taskId, entry: { type: 'native_task_output_file', task_id: taskId, reader_id: entry.id }, boundary: { source: `cursor native background task output terminals/${taskId}.txt`, raw_body_equal: true, encoding: 'none' } } + if (typeof value.text === 'string') { + seen.injections.push({ ...base, text: value.text }) + for (const delivery of parseDelivered(value.text)) seen.deliveries.push({ ...delivery, ...base }) + } + } + } + if (tool.name === 'parent_read') { + const reader = [...seen.tasks].reverse().find(item => item.type === 'task_output_read' && item.result.delivery_id === tool.input?.delivery_id && item.order < order) + if (output.some(value => pageOf(value)) && !reader) fail('ACCEPTANCE_NATIVE_FULL_READER_MISSING', 'Cursor parent_readに先行する同taskの公式全文Readがありません') + addPages(seen, output, { order, session: expectedConversationId, turn_id: turn, id: entry.id, native_task_id: reader?.task_id ?? null, reader_id: reader?.reader_tool_use_id ?? null, reader_order: reader?.order ?? null, source: 'cursor parent_read MCP result (postToolUse hook)' }) + } + } + if (row.role === 'assistant') { + const text = parts.filter(part => part.type === 'text').map(part => part.text ?? '').join('') + if (text.length) seen.replies.push({ order, session: expectedConversationId, turn_id: turn, text }) + } + } + pageDeliveries(seen) + return seen +} + +export function parseGrokNativeUpdates({ transcript, hooks = [], expectedConversationId, file = 'Grok updates.jsonl' }) { + if (!expectedConversationId) fail('ACCEPTANCE_NATIVE_SESSION_REQUIRED', '期待するGrok会話IDが必要です') + const parsed = readBackgroundJsonl(transcript, file), seen = empty(parsed) + hookRows(hooks, expectedConversationId) + const updates = parsed.rows.map(({ order, row }) => { + checkSession(expectedConversationId, row.params?.sessionId, `${file}:${order}`) + checkSession(expectedConversationId, row.params?.update?.task_snapshot?.owner_session_id, `${file}:${order}:task`) + return { order, update: row.params?.update ?? {} } + }) + // Grokの公式prompt_idはturn完了時に記録される。接頭辞を含めて原値を使う。 + const turnAt = new Map(); let upcoming = null + for (let i = updates.length - 1; i >= 0; i--) { + const { update, order } = updates[i] + if (update.sessionUpdate === 'turn_completed') upcoming = update.prompt_id ?? null + turnAt.set(order, upcoming) + } + const calls = new Map(), completed = new Map(), readers = [] + let reply = null + const flush = () => { if (reply) { seen.replies.push(reply); reply = null } } + for (const { order, update: u } of updates) { + const turn = turnAt.get(order), base = { order, session: expectedConversationId, turn_id: turn } + rememberTurn(seen, turn) + if (u.sessionUpdate === 'agent_message_chunk' && typeof u.content?.text === 'string') { + if (reply && reply.turn_id !== turn) flush() + reply ??= { ...base, text: '' }; reply.text += u.content.text + } else if (!['hook_execution', 'agent_thought_chunk'].includes(u.sessionUpdate)) flush() + if (u.sessionUpdate === 'tool_call') { + const name = u.rawInput?.tool_name?.startsWith('peertable_parent__') ? u.rawInput.tool_name.slice('peertable_parent__'.length) : u._meta?.['x.ai/tool']?.name ?? u.title + const entry = { ...base, id: u.toolCallId, name, native_name: u._meta?.['x.ai/tool']?.name ?? u.title, input: name.startsWith('parent_') ? u.rawInput.tool_input : u.rawInput, raw_input: u.rawInput } + seen.toolUses.push(entry); calls.set(entry.id, entry) + } + if (['task_backgrounded', 'task_completed'].includes(u.sessionUpdate)) { + const task = u.task_snapshot ?? u + seen.tasks.push({ ...base, type: u.sessionUpdate, task_id: task.task_id, id: task.task_id, tool_use_id: u.tool_call_id ?? null, output_file: task.output_file, completed_order: u.sessionUpdate === 'task_completed' ? order : null, raw_output: task }) + if (u.sessionUpdate === 'task_completed') completed.set(task.task_id, { ...base, task }) + } + if (u.sessionUpdate !== 'tool_call_update' || !u.rawOutput) continue + const call = calls.get(u.toolCallId), raw = u.rawOutput + if (!call) fail('ACCEPTANCE_NATIVE_TOOL_CORRELATION', 'Grok tool出力に対応する公式tool_callがありません') + call.output = raw; call.output_order = order + if (raw.type === 'BackgroundTaskStarted') seen.tasks.push({ ...base, type: 'task_started_result', id: raw.task_id, tool_use_id: call.id, task_id: raw.task_id, output_file: raw.output_file, pid: raw.pid, raw_output: raw }) + if (call.name === 'get_command_or_subagent_output' && raw.type === 'TaskOutput') { + const result = raw.Result, task = completed.get(result?.task_id) + if (task && call.input?.task_ids?.includes(result.task_id) && !Object.hasOwn(call.input, 'timeout_ms') && result.truncated === false && result.status === 'completed') { + for (const { row } of readBackgroundJsonl(result.output, 'Grok task output').rows) { + if (row.schema === 'peertable.parent-background-result.v1') { + const reader = { ...base, id: call.id, task_id: result.task_id, result: row, completed_order: task.order } + readers.push(reader) + seen.tasks.push({ ...reader, id: result.task_id, reader_tool_use_id: call.id, type: 'task_output_read', output_file: result.output_file, raw_output: result }) + } + } + } + } + if (raw.type === 'MCP' && raw.tool_name === 'parent_read') { + const value = json(raw.output?.OkayOutput, 'Grok parent_read OkayOutput'), page = pageOf(value) + if (!page) continue + const reader = [...readers].reverse().find(item => item.result.delivery_id === page.delivery_id && item.order < order) + if (!reader) fail('ACCEPTANCE_NATIVE_FULL_READER_MISSING', 'Grok parent_readに先行する同taskの公式全文readerがありません') + if (reader.result.digest !== page.digest || (reader.result.endpoint_id !== undefined && reader.result.endpoint_id !== page.endpoint_id)) fail('ACCEPTANCE_NATIVE_TASK_PAGE_MISMATCH', 'Grok全文readerとparent_readのdigest/endpointが一致しません') + addPages(seen, [value], { ...base, id: call.id, native_task_id: reader.task_id, reader_id: reader.id, completed_order: reader.completed_order, reader_order: reader.order }) + } + } + flush(); pageDeliveries(seen) + return seen +} + +export function createBackgroundHarnessObserver({ harness, transcriptPath, updatesPath, hookPaths = [], expectedConversationId, readTaskFile }) { + const parser = { cursor: parseCursorNativeTranscript, grok: parseGrokNativeUpdates }[harness] + if (!parser) fail('ACCEPTANCE_HARNESS_UNSUPPORTED', `未対応の背景harnessです: ${harness}`) + const file = transcriptPath ?? updatesPath + const observe = () => { + const parsedHooks = hookPaths.map(path => readBackgroundJsonl(readFileSync(path), path)) + const hooks = parsedHooks.flatMap(item => item.rows.map(entry => entry.row)) + const seen = parser({ transcript: readFileSync(file), hooks, expectedConversationId, readTaskFile, file }) + seen.hook_pending_tail = parsedHooks.reduce((sum, item) => sum + item.pending_tail, 0) + return seen + } + return { observe, readNativePages: deliveryId => observe().pages.filter(item => item.page.delivery_id === deliveryId) } +} diff --git a/experiments/parent-product-acceptance/background-harness.test.mjs b/experiments/parent-product-acceptance/background-harness.test.mjs new file mode 100644 index 00000000..b5a364f9 --- /dev/null +++ b/experiments/parent-product-acceptance/background-harness.test.mjs @@ -0,0 +1,139 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { readBackgroundJsonl, parseCursorNativeTranscript, parseGrokNativeUpdates, createBackgroundHarnessObserver } from './background-harness.mjs' + +// このfake-native fixtureはparser境界だけを検証する。製品の実機成績には使わない。 +const cid = 'native-conversation', deliveryId = '11111111-1111-1111-1111-111111111111', digest = 'a'.repeat(64) +const jl = rows => Buffer.from(rows.map(row => JSON.stringify(row)).join('\n') + '\n') +const rendered = body => `[Peertable room=room from=worker to=all seq=4]\n本文: ${body}\n[配送ID=${deliveryId} digest=${digest}]` +const background = body => ({ schema: 'peertable.parent-background-result.v1', delivery_id: deliveryId, digest, ...(body !== undefined ? { text: rendered(body) } : {}) }) +const cursorFile = body => Buffer.from(`---\npid: 42\ncommand: node /package/skill/scripts/parent-receive.mjs\nstatus: succeeded\n---\n${JSON.stringify(background(body))}\n\n---\nexit_code: 0\n---\n`) +const cursorFixture = ({ body = '日本語\n 原文\t ', repeat = false, duplicate = false, pendingRead = false } = {}) => { + const paths = ['/native/terminals/42.txt', '/native/terminals/43.txt'], files = new Map(paths.map(path => [path, cursorFile(body)])), hooks = [], rows = [] + const addRead = (path, length, turn) => { + rows.push({ role: 'assistant', message: { content: [{ type: 'tool_use', name: 'Read', input: { path } }] } }) + hooks.push({ event: { hook_event_name: 'postToolUse', conversation_id: cid, generation_id: turn, tool_use_id: `read-${rows.length}`, tool_name: 'Read', tool_input: { file_path: path }, tool_output: JSON.stringify({ file_path: path, content_length: length }) } }) + } + if (pendingRead) addRead(paths[0], 20, 'before-completion') + addRead(paths[0], files.get(paths[0]).toString('utf8').length, 'actual-turn') + if (repeat) addRead(paths[0], files.get(paths[0]).toString('utf8').length, 'repeat-turn') + if (duplicate) addRead(paths[1], files.get(paths[1]).toString('utf8').length, 'duplicate-turn') + rows.push({ role: 'assistant', message: { content: [{ type: 'text', text: `受信: ${body}` }] } }) + return { transcript: jl(rows), hooks, expectedConversationId: cid, readTaskFile: path => files.get(path) } +} +const page = (body, offset, total, complete) => ({ schema: 'peertable.parent-read-page.v1', endpoint_id: 'endpoint', delivery_id: deliveryId, digest, event: { room: 'room', from: 'worker', to: 'all', seq: 4, body }, offset, total_characters: total, complete }) +const grokFixture = (pages, { duplicate = false, noReader = false } = {}) => { + const updates = [], add = update => updates.push({ params: { sessionId: cid, update } }) + add({ sessionUpdate: 'task_completed', task_snapshot: { task_id: 'task-1', owner_session_id: cid, completed: true } }) + if (!noReader) { + add({ sessionUpdate: 'tool_call', toolCallId: 'reader', title: 'get_command_or_subagent_output', rawInput: { task_ids: ['task-1'] } }) + add({ sessionUpdate: 'tool_call_update', toolCallId: 'reader', rawOutput: { type: 'TaskOutput', Result: { task_id: 'task-1', truncated: false, status: 'completed', output: JSON.stringify(background()) + '\n' } } }) + } + for (const [i, value] of (duplicate ? [...pages, ...pages] : pages).entries()) { + add({ sessionUpdate: 'tool_call', toolCallId: `page-${i}`, title: 'use_tool', rawInput: { tool_name: 'peertable_parent__parent_read', tool_input: { delivery_id: deliveryId } } }) + add({ sessionUpdate: 'tool_call_update', toolCallId: `page-${i}`, rawOutput: { type: 'MCP', tool_name: 'parent_read', output: { OkayOutput: JSON.stringify({ schema: 'peertable.parent-read-result.v1', page: value }) } } }) + } + add({ sessionUpdate: 'agent_message_chunk', content: { text: '受信: ' } }) + add({ sessionUpdate: 'hook_execution' }) + add({ sessionUpdate: 'agent_message_chunk', content: { text: '日本語\n 原文\t ' } }) + add({ sessionUpdate: 'turn_completed', prompt_id: 'task-completed-task-1' }) + return { transcript: jl(updates), expectedConversationId: cid } +} + +test('確定JSONL破損はtyped error、追記中末尾はpending_tail', () => { + const source = Buffer.from('{"value":"原文"}\n{"incomplete":') + const seen = readBackgroundJsonl(source) + assert.equal(seen.rows.length, 1); assert.equal(seen.pending_tail, 14) + assert.throws(() => readBackgroundJsonl(Buffer.from('{}\n壊れた行\n')), { code: 'ACCEPTANCE_TRANSCRIPT_CORRUPT' }) +}) + +test('先頭BOMはJSON decodeだけで処理し原bytesと本文を保持', () => { + const fixture = cursorFixture(), original = fixture.transcript + fixture.transcript = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), original]) + const before = Buffer.from(fixture.transcript), seen = parseCursorNativeTranscript(fixture) + assert.deepEqual(fixture.transcript, before) + assert.equal(seen.deliveries[0].body, '日本語\n 原文\t ') +}) + +test('Cursor完了前Readを除き、同taskの再Readは配送を重複算入しない', () => { + const seen = parseCursorNativeTranscript(cursorFixture({ pendingRead: true, repeat: true })) + assert.equal(seen.deliveries.length, 1) + assert.equal(seen.deliveries[0].turn_id, 'actual-turn') + assert.equal(seen.deliveries[0].order, 1) + assert.equal(seen.toolUses.length, 3) +}) + +test('別native taskに同配送が出た真の重複は残す', () => { + const seen = parseCursorNativeTranscript(cursorFixture({ duplicate: true })) + assert.equal(seen.deliveries.length, 2) + assert.deepEqual(seen.deliveries.map(item => item.native_task_id), ['42', '43']) +}) + +test('Cursor hookとGrok session/taskに別CIDが入ればtyped error', () => { + const cursor = cursorFixture(); cursor.hooks[0].event.conversation_id = 'other' + assert.throws(() => parseCursorNativeTranscript(cursor), { code: 'ACCEPTANCE_NATIVE_SESSION_MISMATCH' }) + const grok = grokFixture([page('a', 0, 1, true)]), rows = readBackgroundJsonl(grok.transcript).rows.map(item => item.row) + rows[0].params.update.task_snapshot.owner_session_id = 'other' + assert.throws(() => parseGrokNativeUpdates({ ...grok, transcript: jl(rows) }), { code: 'ACCEPTANCE_NATIVE_SESSION_MISMATCH' }) +}) + +test('Grok長文pageを無加工で束ね公式task-completed turnと返答chunkを保持', () => { + const first = '日本語\n', last = ' 原文\t ', pages = [page(first, 0, first.length + last.length, false), page(last, first.length, first.length + last.length, true)] + const seen = parseGrokNativeUpdates(grokFixture(pages)) + assert.deepEqual(seen.pages.map(item => item.page), pages) + assert.equal(seen.deliveries.length, 1) + assert.equal(seen.deliveries[0].body, first + last) + assert.equal(seen.deliveries[0].turn_id, 'task-completed-task-1') + assert.equal(seen.replies[0].text, '受信: 日本語\n 原文\t ') + assert.ok(seen.pages.every(item => item.completed_order < item.reader_order && item.reader_order < item.order)) +}) + +test('Grok全文readerなしを受信にしない、complete後の二重page回収は残す', () => { + const pages = [page('a', 0, 1, true)] + assert.throws(() => parseGrokNativeUpdates(grokFixture(pages, { noReader: true })), { code: 'ACCEPTANCE_NATIVE_FULL_READER_MISSING' }) + assert.equal(parseGrokNativeUpdates(grokFixture(pages, { duplicate: true })).deliveries.length, 2) +}) + +test('Cursor previewと公式長文page回収は一配送として関連付ける', () => { + const fixture = cursorFixture({ body: '前半' }), rows = readBackgroundJsonl(fixture.transcript).rows.map(item => item.row), body = '前半\n 後半 ' + // native背景previewの正式な末尾だけをfixture生成時に指定する。 + const nativePath = '/native/terminals/42.txt', bytes = Buffer.from(cursorFile('前半').toString().replace(`digest=${digest}]`, `digest=${digest} 全文取得=parent_read]`)) + fixture.readTaskFile = () => bytes + fixture.hooks[0].event.tool_output = JSON.stringify({ file_path: nativePath, content_length: bytes.toString().length }) + const pages = [page(body.slice(0, 3), 0, body.length, false), page(body.slice(3), 3, body.length, true)] + for (const [i, value] of pages.entries()) { + const args = { delivery_id: deliveryId, endpoint_id: 'endpoint', ...(i ? { continuation_key: 'opaque' } : {}) } + rows.push({ role: 'assistant', message: { content: [{ type: 'tool_use', name: 'CallDynamicTool', input: { namespace: 'peertable_parent', toolName: 'parent_read', arguments: args } }] } }) + fixture.hooks.push({ input: { conversation_id: cid, generation_id: 'actual-turn', hook_event_name: 'postToolUse', tool_name: 'MCP:parent_read', tool_input: args, tool_use_id: `parent-${i}`, tool_output: JSON.stringify({ content: [{ type: 'text', text: JSON.stringify({ schema: 'peertable.parent-read-result.v1', page: value }) }], structuredContent: { schema: 'peertable.parent-read-result.v1', page: value } }) } }) + } + const seen = parseCursorNativeTranscript({ ...fixture, transcript: jl(rows) }) + assert.equal(seen.deliveries.length, 1); assert.equal(seen.deliveries[0].preview, true) + assert.deepEqual(seen.pages.map(item => item.page), pages) +}) + +// 外部保存データの再観測はparserの受入で、過去の製品成績の新規作成ではない。 +test('保存済み6cellの原文18件・実turn・後続返答を既存公開証拠と照合', { skip: !process.env.BACKGROUND_NATIVE_EVIDENCE_DIR }, () => { + const root = process.env.BACKGROUND_NATIVE_EVIDENCE_DIR + let count = 0 + for (const cell of readdirSync(join(root, 'raw'))) { + const dir = join(root, 'raw', cell), harness = cell.endsWith('cursor') ? 'cursor' : 'grok' + const state = JSON.parse(readFileSync(join(dir, 'final-state.json'))), session = state.spools[0].caller.conversation + const os = cell.includes('-mac-') ? 'darwin' : cell.includes('-win-') ? 'win32' : 'linux' + const evidence = JSON.parse(readFileSync(join(root, 'public/rag/parent-delivery/live', os, harness, 'cli/audience.json'))) + const observer = createBackgroundHarnessObserver({ harness, expectedConversationId: session, hookPaths: [join(dir, 'hook-events.jsonl')], transcriptPath: harness === 'cursor' ? join(dir, 'agent-transcripts', session, `${session}.jsonl`) : join(dir, 'grok-updates.jsonl'), readTaskFile: path => readFileSync(join(dir, 'terminals', path.split(/[\\/]/u).at(-1))) }) + const seen = observer.observe() + for (const check of evidence.checks) { + const matches = seen.deliveries.filter(item => item.delivery_id === check.delivery.delivery_id) + assert.equal(matches.length, 1, `${cell}:${check.label}`) + const received = matches[0] + assert.deepEqual({ room: received.room, from: received.from, to: received.to, seq: received.seq, body: received.body }, check.received) + assert.equal(received.turn_id, check.delivery.turn_id) + assert.equal(received.order, check.delivery.order) + assert.ok(seen.replies.some(reply => reply.session === session && reply.order > received.order && reply.turn_id === check.reply.turn_id && reply.text === check.reply.text)) + count++ + } + } + assert.equal(count, 18) +}) From 2ee1f9164b41e84dda1141ce3897eb2756866a9c Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:02:47 +0900 Subject: [PATCH 27/72] =?UTF-8?q?Cursor=E5=85=A8=E6=96=87Read=E3=81=A8?= =?UTF-8?q?=E5=9B=9E=E5=8F=8Epage=E3=81=AEdigest=E3=81=A8endpoint=E3=82=92?= =?UTF-8?q?=E7=85=A7=E5=90=88=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../background-harness.mjs | 4 ++++ .../background-harness.test.mjs | 20 ++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/experiments/parent-product-acceptance/background-harness.mjs b/experiments/parent-product-acceptance/background-harness.mjs index 57642312..c911035a 100644 --- a/experiments/parent-product-acceptance/background-harness.mjs +++ b/experiments/parent-product-acceptance/background-harness.mjs @@ -135,6 +135,10 @@ export function parseCursorNativeTranscript({ transcript, hooks = [], expectedCo if (tool.name === 'parent_read') { const reader = [...seen.tasks].reverse().find(item => item.type === 'task_output_read' && item.result.delivery_id === tool.input?.delivery_id && item.order < order) if (output.some(value => pageOf(value)) && !reader) fail('ACCEPTANCE_NATIVE_FULL_READER_MISSING', 'Cursor parent_readに先行する同taskの公式全文Readがありません') + for (const value of output) { + const page = pageOf(value) + if (page && (reader.result.digest !== page.digest || (reader.result.endpoint_id !== undefined && reader.result.endpoint_id !== page.endpoint_id))) fail('ACCEPTANCE_NATIVE_TASK_PAGE_MISMATCH', 'Cursor全文Readとparent_readのdigest/endpointが一致しません') + } addPages(seen, output, { order, session: expectedConversationId, turn_id: turn, id: entry.id, native_task_id: reader?.task_id ?? null, reader_id: reader?.reader_tool_use_id ?? null, reader_order: reader?.order ?? null, source: 'cursor parent_read MCP result (postToolUse hook)' }) } } diff --git a/experiments/parent-product-acceptance/background-harness.test.mjs b/experiments/parent-product-acceptance/background-harness.test.mjs index b5a364f9..0be5f3a4 100644 --- a/experiments/parent-product-acceptance/background-harness.test.mjs +++ b/experiments/parent-product-acceptance/background-harness.test.mjs @@ -8,7 +8,7 @@ import { readBackgroundJsonl, parseCursorNativeTranscript, parseGrokNativeUpdate const cid = 'native-conversation', deliveryId = '11111111-1111-1111-1111-111111111111', digest = 'a'.repeat(64) const jl = rows => Buffer.from(rows.map(row => JSON.stringify(row)).join('\n') + '\n') const rendered = body => `[Peertable room=room from=worker to=all seq=4]\n本文: ${body}\n[配送ID=${deliveryId} digest=${digest}]` -const background = body => ({ schema: 'peertable.parent-background-result.v1', delivery_id: deliveryId, digest, ...(body !== undefined ? { text: rendered(body) } : {}) }) +const background = body => ({ schema: 'peertable.parent-background-result.v1', endpoint_id: 'endpoint', delivery_id: deliveryId, digest, ...(body !== undefined ? { text: rendered(body) } : {}) }) const cursorFile = body => Buffer.from(`---\npid: 42\ncommand: node /package/skill/scripts/parent-receive.mjs\nstatus: succeeded\n---\n${JSON.stringify(background(body))}\n\n---\nexit_code: 0\n---\n`) const cursorFixture = ({ body = '日本語\n 原文\t ', repeat = false, duplicate = false, pendingRead = false } = {}) => { const paths = ['/native/terminals/42.txt', '/native/terminals/43.txt'], files = new Map(paths.map(path => [path, cursorFile(body)])), hooks = [], rows = [] @@ -113,6 +113,24 @@ test('Cursor previewと公式長文page回収は一配送として関連付け assert.deepEqual(seen.pages.map(item => item.page), pages) }) +const cursorPageFixture = value => { + const fixture = cursorFixture({ body: 'a' }), rows = readBackgroundJsonl(fixture.transcript).rows.map(item => item.row) + const args = { delivery_id: deliveryId, endpoint_id: 'endpoint' } + rows.push({ role: 'assistant', message: { content: [{ type: 'tool_use', name: 'CallDynamicTool', input: { namespace: 'peertable_parent', toolName: 'parent_read', arguments: args } }] } }) + fixture.hooks.push({ event: { conversation_id: cid, generation_id: 'actual-turn', hook_event_name: 'postToolUse', tool_name: 'MCP:parent_read', tool_input: args, tool_use_id: 'parent-page', tool_output: JSON.stringify({ content: [{ type: 'text', text: JSON.stringify({ schema: 'peertable.parent-read-result.v1', page: value }) }] }) } }) + return { ...fixture, transcript: jl(rows) } +} + +test('Cursor同配送IDでも全文Readとpageのdigestが異なれば拒否', () => { + const value = { ...page('a', 0, 1, true), digest: 'b'.repeat(64) } + assert.throws(() => parseCursorNativeTranscript(cursorPageFixture(value)), { code: 'ACCEPTANCE_NATIVE_TASK_PAGE_MISMATCH' }) +}) + +test('Cursor同配送IDでも全文Readとpageのendpointが異なれば拒否', () => { + const value = { ...page('a', 0, 1, true), endpoint_id: 'other-endpoint' } + assert.throws(() => parseCursorNativeTranscript(cursorPageFixture(value)), { code: 'ACCEPTANCE_NATIVE_TASK_PAGE_MISMATCH' }) +}) + // 外部保存データの再観測はparserの受入で、過去の製品成績の新規作成ではない。 test('保存済み6cellの原文18件・実turn・後続返答を既存公開証拠と照合', { skip: !process.env.BACKGROUND_NATIVE_EVIDENCE_DIR }, () => { const root = process.env.BACKGROUND_NATIVE_EVIDENCE_DIR From 02c0c130dd35950ce8a983505a5027836be05c4a Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:07:40 +0900 Subject: [PATCH 28/72] =?UTF-8?q?=E8=83=8C=E6=99=AF=E5=8F=97=E4=BF=A1?= =?UTF-8?q?=E3=81=AE=E5=85=AC=E5=BC=8Freader=E3=81=A8Codex=E5=81=9C?= =?UTF-8?q?=E6=AD=A2=E6=BA=96=E5=82=99=E3=82=92=E7=85=A7=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scenarios-context.mjs | 16 +++++++++++++--- .../parent-product-acceptance/scenarios.mjs | 6 +++--- .../parent-product-acceptance/scenarios.test.mjs | 16 +++++++++++++++- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/experiments/parent-product-acceptance/scenarios-context.mjs b/experiments/parent-product-acceptance/scenarios-context.mjs index a2d10610..0acbc41c 100644 --- a/experiments/parent-product-acceptance/scenarios-context.mjs +++ b/experiments/parent-product-acceptance/scenarios-context.mjs @@ -137,6 +137,16 @@ export function nativeReceiveToolAllowed(use, registrations, session) { }) } +// 受信taskの全量readerも受信維持の一部。実task/readの相関がないRead等は許可しない。 +export function nativeReceiveReaderAllowed(use, registrations, tasks, session) { + const read = tasks.find(task => task.reader_tool_use_id === use.id && task.session === session && task.result?.schema === 'peertable.parent-background-result.v1') + if (!read || (use.session ?? use.parent_session) !== session) return false + const registration = registrations.find(item => item.owner_verified === true && item.parent_session === session && item.native_task.id === String(read.task_id ?? read.id) && item.endpoint_id === read.result.endpoint_id) + if (!registration || !registration.native_task.process_identity?.started || !registration.waiter_owner.started) return false + if (registration.native_task.input?.name === 'Shell') return use.name === 'Read' && use.input?.path === read.output_file && !Object.hasOwn(use.input, 'offset') && !Object.hasOwn(use.input, 'limit') + return registration.native_task.input?.name === 'run_terminal_command' && use.name === 'get_command_or_subagent_output' && isDeepStrictEqual(use.input, { task_ids: [registration.native_task.id] }) +} + export async function createScenarioContext(options) { const { meta, spool, api, observe, submit, file, pkg, projectDir, privateDir, proxy, bin, nativeActions = {}, screen } = options const importInstalled = name => import(pathToFileURL(join(pkg, 'skill/scripts', name)).href) @@ -182,7 +192,7 @@ export async function createScenarioContext(options) { const record = recordFor(posted), receipt = await receiptFor(posted) let deliveries = seen.deliveries const preview = deliveries.find(item => item.delivery_id === record.delivery_id && item.preview) - const pages = nativeReadPages(targetMeta.harness, target.file, record.delivery_id) + const pages = ['cursor', 'grok'].includes(targetMeta.harness) ? (seen.pages ?? []).filter(item => item.page.delivery_id === record.delivery_id) : nativeReadPages(targetMeta.harness, target.file, record.delivery_id) if (preview || pages.length) { const assembled = assembleNativePages(pages) if (!assembled || pages.some(item => item.page.endpoint_id !== target.spool.id)) fail('ACCEPTANCE_NATIVE_WHOLE_BODY_MISSING', `${scope.scenario}: parent_read最終page/endpointが不一致です`) @@ -305,7 +315,7 @@ export async function createScenarioContext(options) { actions.tools_observe = async (_, scope) => { const mode = scope.scenario === 'no_tools' ? 'none' : 'parent_only', before = faultState.get(`${scope.runId}:policy:${mode}`) const used = observe().toolUses.filter(item => item.order > before.order && item.name !== 'output') - if (mode === 'none' ? used.length : used.some(item => !/parent_(?:join|read|leave)/u.test(item.name) && !nativeReceiveToolAllowed(item, nativeRegistrations, meta.parent_session))) fail('ACCEPTANCE_TOOL_POLICY_VIOLATED', JSON.stringify(used.map(item => item.name))) + if (mode === 'none' ? used.length : used.some(item => !/parent_(?:join|read|leave)/u.test(item.name) && !nativeReceiveToolAllowed(item, nativeRegistrations, meta.parent_session) && !nativeReceiveReaderAllowed(item, nativeRegistrations, observe().tasks ?? [], meta.parent_session))) fail('ACCEPTANCE_TOOL_POLICY_VIOLATED', JSON.stringify(used.map(item => item.name))) const noTools = mode === 'none' ? await Promise.all(pending.filter(item => item.scope.runId === scope.runId && item.allowRetained).map(async item => { const target = targetFor(item.posted); return noToolsReception({ posted: item.posted, record: recordFor(item.posted), receipt: await receiptFor(item.posted), seen: target.observe(), session: target.meta.parent_session, runtime: target.spool.read().runtime }) })) : [] return result(scope, mode === 'none' ? 'no_tools_or_explicit_rearm' : 'no_external_tools', 'harness_transcript', { tools: used, native_registrations: nativeRegistrations, runtime: spool.read().runtime, no_tools_states: noTools.map(item => item.state) }) } @@ -342,7 +352,7 @@ export async function createScenarioContext(options) { const records = posted.map(item => recordFor(item)).filter(Boolean) return records.find(item => item.state === 'sending' && item.claim.offset > 0 && item.claim.offset < item.event.body.length && item.receipt === null) }, 120000, 50) - const pages = nativeReadPages(meta.harness, file, sample.delivery_id) + const pages = ['cursor', 'grok'].includes(meta.harness) ? (observe().pages ?? []).filter(item => item.page.delivery_id === sample.delivery_id) : nativeReadPages(meta.harness, file, sample.delivery_id) if (!pages.length || pages.at(-1).page.complete) fail('ACCEPTANCE_BURST_INTERMEDIATE_PAGE_MISSING', '未読状態に相関する実tool途中pageがありません') return result(scope, 'unread_until_last_page', 'harness_transcript', { delivery_id: sample.delivery_id, offset: sample.claim.offset, receipt: sample.receipt, native_page_count: pages.length }) } diff --git a/experiments/parent-product-acceptance/scenarios.mjs b/experiments/parent-product-acceptance/scenarios.mjs index 1f96ebfd..d84c10d6 100644 --- a/experiments/parent-product-acceptance/scenarios.mjs +++ b/experiments/parent-product-acceptance/scenarios.mjs @@ -18,7 +18,7 @@ export const receiverBoundaries = { } // 文字数上限は導入物のPAGE_CHARSをcontextから受け取る。上限をrunnerに複製しない。 -export function scenarioPlan(name, { pageChars } = {}) { +export function scenarioPlan(name, { pageChars, harness = 'claude' } = {}) { const plans = { busy: [native('work_start', 'work_running'), send(), native('work_finish', 'work_continued')], final_race: [native('final_arm', 'final_boundary_armed'), send('終了境界'), native('final_observe', 'final_no_loss_duplicate')], @@ -29,7 +29,7 @@ export function scenarioPlan(name, { pageChars } = {}) { original: [send('日本語・引用・改行', { body: `日本語😀\n「引用」 'q' "double" & 字面>\n\\path\\改行\r\n末尾\n` }), send('長文全量', { long: true }), native('pages_observe', 'whole_body_recovered')], burst: [native('burst_start', 'burst_above_page_limit', { count: 4 }), native('burst_observe', 'unread_until_last_page'), native('burst_finish', 'burst_seq_whole_body')], source_reconnect: [native('source_disconnect', 'http_sse_disconnected'), send('切断中', { defer: true }), native('source_reconnect', 'http_sse_catchup'), native('pending_recover', 'native_delivery'), send('再接続後'), native('sequence_observe', 'seq_order')], - process_recovery: [native('receiver_suspend', 'own_receiver_suspended'), send('ready保持', { defer: true }), native('receiver_restart', 'ready_inherited'), native('pending_recover', 'native_delivery'), native('sending_interrupt', 'sending_unknown_no_resend'), native('submitted_restart', 'submitted_not_resubmitted'), send('復旧後')], + process_recovery: [native('receiver_suspend', harness === 'codex' ? 'own_receiver_suspension_armed' : 'own_receiver_suspended'), send('ready保持', { defer: true }), native('receiver_restart', 'ready_inherited'), native('pending_recover', 'native_delivery'), native('sending_interrupt', 'sending_unknown_no_resend'), native('submitted_restart', 'submitted_not_resubmitted'), send('復旧後')], claim_race: [native('claim_race_arm', 'own_claim_competitors'), send('claim競合'), native('claim_race_observe', 'single_claim_output')], output_interruption: [native('output_interrupt_arm', 'own_output_boundary_armed'), send('中断対象', { defer: true }), native('output_interrupt', 'claim_output_unknown_preserved'), native('output_unknown_observe', 'unknown_not_resent'), send('中断後別本文')], receipt_retry: [native('receipt_fail_arm', 'own_receipt_http_failure'), send('receipt失敗', { defer: true }), native('receipt_failure_observe', 'native_output_receipt_pending'), native('receipt_restore', 'receipt_only_recovered'), native('pending_recover', 'native_delivery'), native('receipt_retry_observe', 'output_once_receipt_revision')], @@ -74,7 +74,7 @@ export function validateNativeCheck(check, { scenario, runId, session }) { export async function runScenario(name, context) { const { harness, session, pageChars, runId = randomUUID() } = context if (!receiverBoundaries[harness]) fail('ACCEPTANCE_HARNESS_UNSUPPORTED', harness) - const plan = scenarioPlan(name, { pageChars }) + const plan = scenarioPlan(name, { pageChars, harness }) const missing = [...new Set(plan.map(item => item.action))].filter(action => typeof context.actions?.[action] !== 'function') if (missing.length) fail('ACCEPTANCE_SCENARIO_ADAPTER_MISSING', `${harness}/${name}: ${missing.join(', ')}`) const checks = [], observations = [], trace = [], nonces = new Set() diff --git a/experiments/parent-product-acceptance/scenarios.test.mjs b/experiments/parent-product-acceptance/scenarios.test.mjs index f33dfea7..c430c2e6 100644 --- a/experiments/parent-product-acceptance/scenarios.test.mjs +++ b/experiments/parent-product-acceptance/scenarios.test.mjs @@ -7,7 +7,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { scenarios as inventory } from '../../scripts/parent-delivery-acceptance.mjs' import { scenarioNames, scenarioPlan, runScenario, validateBoundary, validateNativeCheck } from './scenarios.mjs' -import { createScenarioProxy, nativeReadPages, assembleNativePages, noToolsReception, codexTurns, assertCodexBusy, nativeReceiveToolAllowed } from './scenarios-context.mjs' +import { createScenarioProxy, nativeReadPages, assembleNativePages, noToolsReception, codexTurns, assertCodexBusy, nativeReceiveToolAllowed, nativeReceiveReaderAllowed } from './scenarios-context.mjs' const check = () => ({ run_id: 'run', scenario: 'package', status: 'passed', body_equal: true, count: 1, nonce: 'unique', harness: 'claude', original: { room: 'room', from: 'probe', to: 'bell', seq: 1, body: '日本語 & 字面>' }, received: { room: 'room', from: 'probe', to: 'bell', seq: 1, body: '日本語 & 字面>' }, delivery: { session: 'session', turn_id: 'turn', order: 2 }, reply: { session: 'session', turn_id: 'turn2', order: 3, text: 'unique' }, receipt: { result: 'delivered', receipt_revision: 1 } }) const checkScope = { runId: 'run', scenario: 'package', session: 'session' } @@ -139,3 +139,17 @@ test('受信維持Shellは完成済み入力・実task ID・PID・所有相関 assert.equal(nativeReceiveToolAllowed({ ...use, session: 'other-session' }, [registration], 's'), false) assert.equal(nativeReceiveToolAllowed(use, [{ ...registration, owner_verified: false }], 's'), false) }) + +test('全量readerは製品の実taskと公式Read/get出力の相関がある時だけ許可する', () => { + const registration = { parent_session: 's', endpoint_id: 'endpoint', owner_verified: true, waiter_owner: { pid: 10, started: 'waiter' }, native_task: { id: 'task', process_identity: { pid: 20, started: 'task' }, input: { name: 'Shell' } } } + const task = { id: 'task', reader_tool_use_id: 'read', session: 's', output_file: '/own/task.txt', result: { schema: 'peertable.parent-background-result.v1', endpoint_id: 'endpoint' } } + const read = { id: 'read', name: 'Read', session: 's', input: { path: '/own/task.txt' } } + assert.equal(nativeReceiveReaderAllowed(read, [registration], [task], 's'), true) + assert.equal(nativeReceiveReaderAllowed({ ...read, input: { path: '/other/task.txt' } }, [registration], [task], 's'), false) + assert.equal(nativeReceiveReaderAllowed({ ...read, input: { ...read.input, limit: 1 } }, [registration], [task], 's'), false) + assert.equal(nativeReceiveReaderAllowed(read, [registration], [{ ...task, id: 'foreign' }], 's'), false) + registration.native_task.input.name = 'run_terminal_command' + const get = { ...read, name: 'get_command_or_subagent_output', input: { task_ids: ['task'] } } + assert.equal(nativeReceiveReaderAllowed(get, [registration], [task], 's'), true) + assert.equal(nativeReceiveReaderAllowed({ ...get, input: { ...get.input, timeout_ms: 1 } }, [registration], [task], 's'), false) +}) From 91dbac57070ca0ba8aff9208cb836dd8e8d556da Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:07:54 +0900 Subject: [PATCH 29/72] =?UTF-8?q?=E9=80=9A=E5=B8=B8=E8=AA=8D=E8=A8=BC?= =?UTF-8?q?=E3=81=AE=E5=B0=82=E7=94=A8=E8=A6=AAfixture=E3=81=A8native?= =?UTF-8?q?=E9=9A=9C=E5=AE=B3=E8=A6=B3=E6=B8=AC=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scenarios-fixtures.mjs | 280 +++++++++ .../scenarios-fixtures.test.mjs | 53 ++ .../scenarios-lease-run.mjs | 68 +++ .../scenarios-native.mjs | 574 ++++++++++++++++++ .../scenarios-native.test.mjs | 77 +++ .../scenarios-surfaces.mjs | 87 +++ 6 files changed, 1139 insertions(+) create mode 100644 experiments/parent-product-acceptance/scenarios-fixtures.mjs create mode 100644 experiments/parent-product-acceptance/scenarios-fixtures.test.mjs create mode 100644 experiments/parent-product-acceptance/scenarios-lease-run.mjs create mode 100644 experiments/parent-product-acceptance/scenarios-native.mjs create mode 100644 experiments/parent-product-acceptance/scenarios-native.test.mjs create mode 100644 experiments/parent-product-acceptance/scenarios-surfaces.mjs diff --git a/experiments/parent-product-acceptance/scenarios-fixtures.mjs b/experiments/parent-product-acceptance/scenarios-fixtures.mjs new file mode 100644 index 00000000..b94fa5a9 --- /dev/null +++ b/experiments/parent-product-acceptance/scenarios-fixtures.mjs @@ -0,0 +1,280 @@ +// 通常HOME・公式認証・user設定を共有し、試験projectと公式session指定だけを所有する。 +import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, realpathSync, rmSync } from 'node:fs' +import { join, dirname, relative, isAbsolute } from 'node:path' +import { homedir } from 'node:os' +import { execFileSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { pathToFileURL } from 'node:url' +import { isDeepStrictEqual } from 'node:util' +import { openAiterm } from './aiterm.mjs' +import { resolveCli, startupAction, readTranscript, transcriptPath } from './harness.mjs' +import { parseDelivered, sha256 } from './evidence.mjs' + +const fail = (code, message) => { throw Object.assign(new Error(message), { code }) } +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) +const until = async (label, probe, ms = 180000, interval = 250) => { const end = Date.now() + ms; for (;;) { const value = await probe(); if (value) return value; if (Date.now() >= end) fail('ACCEPTANCE_FIXTURE_TIMEOUT', label); await sleep(interval) } } +const contained = (root, file) => { const path = relative(realpathSync(root), file); return path !== '' && !path.startsWith('..') && !isAbsolute(path) } +const writeJson = (file, value) => { mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); writeFileSync(file, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 }) } + +export function hookConfigurationSnapshot(hooks) { + const beforeBytes = existsSync(hooks) ? readFileSync(hooks) : null + const beforeHooks = beforeBytes ? JSON.parse(beforeBytes.toString('utf8').replace(/^\uFEFF/u, '')) : null + return { hooks, beforeHooks, beforeBytes, assertHooksUnchanged() { const after = existsSync(hooks) ? readFileSync(hooks) : null; if (Boolean(after) !== Boolean(beforeBytes) || (after && !after.equals(beforeBytes))) fail('ACCEPTANCE_USER_HOOK_CONFIG_CHANGED', hooks) } } +} + +// 関連する通常設定の読取りだけ。認証保存物は開かず、harness自身へ任せる。 +export function ordinaryConfiguration(harness) { + const home = homedir() + const config = harness === 'codex' ? process.env.CODEX_HOME ?? join(home, '.codex') : harness === 'claude' ? process.env.CLAUDE_CONFIG_DIR ?? join(home, '.claude') : harness === 'cursor' ? process.env.CURSOR_HOME ?? join(home, '.cursor') : join(home, '.grok') + const hooks = join(config, harness === 'claude' ? 'settings.json' : harness === 'grok' ? 'hooks/peertable-parent.json' : 'hooks.json') + return { home, config, ...hookConfigurationSnapshot(hooks) } +} + +// 親終了直後の中間状態で失敗にしない。製品による自己停止と索引撤去を実際に待つ。 +export async function waitOwnedFixtureExit({ readEndpoints, sameProcess, knownOwners = [], indexExists, timeout = 30000 }) { + const final = await until('own親/process/索引の製品自己撤去', () => { + const endpoints = readEndpoints(), states = endpoints.map(endpoint => ({ id: endpoint.id, state: endpoint.read() })) + const alive = [...knownOwners, ...states.flatMap(item => [item.state.caller?.owner, item.state.watcher, item.state.waiter?.owner].filter(Boolean))].filter(owner => sameProcess(owner)) + return !alive.length && states.every(item => item.state.runtime === 'stopped' && !indexExists(item.id)) ? { endpoints: states.map(item => ({ id: item.id, runtime: item.state.runtime })), known_processes_gone: true, indexes_gone: true } : null + }, timeout, 100) + return final +} + +export function assertOnlyProjectTrustChanged(before, after, project, { remove = false } = {}) { + const expected = structuredClone(before) + if (remove) { if (!expected.projects?.[project]) fail('ACCEPTANCE_PROJECT_TRUST_BASELINE_MISSING', project); delete expected.projects[project]; if (!Object.keys(expected.projects).length) delete expected.projects } + else { if (expected.projects?.[project]) fail('ACCEPTANCE_PROJECT_TRUST_ALREADY_PRESENT', project); expected.projects ??= {}; expected.projects[project] = { trust_level: 'trusted' } } + if (!isDeepStrictEqual(expected, after)) fail('ACCEPTANCE_PROJECT_TRUST_OTHER_SETTINGS_CHANGED', '専用project trust以外の実効設定が変わりました') +} + +export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUrl, sourceMeta, apiFor, model, surfaceAdapters = {} }) { + if (!apiFor) { + const token = /^PEERTABLE_POST_TOKEN=(.+)$/mu.exec(readFileSync(tokenFile, 'utf8'))?.[1] + if (!token) fail('ACCEPTANCE_FIXTURE_ROOM_TOKEN_MISSING', '試験roomのtoken参照を確認できません') + apiFor = room => async (path, body) => { const response = await fetch(`${serverUrl}/api/${encodeURIComponent(room)}/${path}`, { method: body ? 'POST' : 'GET', headers: { 'Content-Type': 'application/json', 'X-Peertable-Token': token }, body: body ? JSON.stringify(body) : undefined }); if (!response.ok) fail('ACCEPTANCE_FIXTURE_ROOM_HTTP', `${path}:${response.status}`); return response.json() } + } + const fixtures = new Set(), platform = await import(pathToFileURL(join(pkg, 'skill/scripts/parent-platform.mjs')).href) + const { projectEndpoints } = await import(pathToFileURL(join(pkg, 'skill/scripts/parent-runtime.mjs')).href) + const { ownsParentConnection } = await import(pathToFileURL(join(pkg, 'skill/scripts/parent-connect.mjs')).href) + const { codexConnection } = await import(pathToFileURL(join(pkg, 'skill/scripts/parent-receivers/codex.mjs')).href) + const root = join(out, 'native-fixtures'); mkdirSync(root, { recursive: true, mode: 0o700 }) + return { + api: room => apiFor(room), + async open({ harness = sourceMeta.harness, room = sourceMeta.room, name = `fixture-${randomUUID().slice(0, 8)}`, initialJoin = true, sessionSettings = null, prepare } = {}) { + const directory = join(root, `${harness}-${randomUUID()}`); mkdirSync(directory, { recursive: true, mode: 0o700 }) + const project = join(directory, 'project'); mkdirSync(join(project, '.team'), { recursive: true, mode: 0o700 }); writeJson(join(project, '.team/setup-state.json'), { room, server_url: serverUrl, mode: 'adhoc' }) + const configuration = ordinaryConfiguration(harness) + if (['claude', 'codex'].includes(harness) && !ownsParentConnection(harness)) fail('ACCEPTANCE_FIXTURE_PARENT_NOT_CONNECTED', '通常設定のPeertable所有登録が必要です。fixtureはglobal登録を変更しません') + let adapter = surfaceAdapters[harness] ?? (['claude', 'codex'].includes(harness) ? { resolveCli: () => resolveCli(harness), startup: view => startupAction(harness, view), transcript: session => transcriptPath(harness, session), read: file => readTranscript(harness, file) } : null) + const fixture = { directory, project, configuration, pkg, harness, name, room, pty: null, aiterm: null, target: null, session: null, owner: null, paneOwner: null, nativeOwners: [], receiverOwners: [], ready: false, backups: new Map(), closed: false, observations: [], sessionSettings, adapter } + if (adapter?.bind) adapter = adapter.bind(fixture) + fixture.adapter = adapter + if (!adapter?.resolveCli || !adapter.startup || !adapter.transcript || !adapter.read) fail('ACCEPTANCE_OFFICIAL_SURFACE_BOUNDARY_UNCONFIRMED', `${harness}: 公式task/transcriptの実測adapterが必要です`) + fixture.trackOwnProcesses = async () => { + if (!fixture.pty) return + const pane = await fixture.aiterm.observe(fixture.pty) + if (pane.pane_process?.pid && !fixture.paneOwner) fixture.paneOwner = platform.processIdentity(pane.pane_process.pid) + for (const endpoint of projectEndpoints(project)) { + const state = endpoint.read() + if (state.caller?.owner && platform.sameProcess(state.caller.owner)) { fixture.owner = state.caller.owner; if (!fixture.nativeOwners.some(owner => owner.pid === state.caller.owner.pid && owner.started === state.caller.owner.started)) fixture.nativeOwners.push(state.caller.owner) } + for (const owner of [state.watcher, state.waiter?.owner].filter(Boolean)) if (!fixture.receiverOwners.some(item => item.pid === owner.pid && item.started === owner.started)) fixture.receiverOwners.push(owner) + } + if (!fixture.paneOwner || !platform.sameProcess(fixture.paneOwner)) return + let rows + if (process.platform === 'win32') { + const raw = execFileSync('pwsh.exe', ['-NoProfile', '-NonInteractive', '-Command', 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId | ConvertTo-Json -Compress'], { encoding: 'utf8' }) + rows = JSON.parse(raw); if (!Array.isArray(rows)) rows = [rows] + rows = rows.map(row => ({ pid: row.ProcessId, parent: row.ParentProcessId })) + } else rows = execFileSync('/bin/ps', ['-A', '-o', 'pid=', '-o', 'ppid='], { encoding: 'utf8' }).trim().split('\n').map(line => { const [pid, parent] = line.trim().split(/\s+/u).map(Number); return { pid, parent } }) + const descendants = new Set([fixture.paneOwner.pid]); let added = true + while (added) { added = false; for (const row of rows) if (descendants.has(row.parent) && !descendants.has(row.pid)) { descendants.add(row.pid); added = true } } + for (const pid of descendants) { + const owner = platform.processIdentity(pid) + if (owner && platform.processHarness(owner) === harness && !fixture.nativeOwners.some(item => item.pid === owner.pid && item.started === owner.started)) { fixture.nativeOwners.push(owner); fixture.owner ??= owner } + } + } + fixture.rpc = async (method, params) => { + if (harness !== 'codex') fail('ACCEPTANCE_FIXTURE_RPC_HARNESS', harness) + const client = await codexConnection(project, { executable: fixture.owner?.executable ?? platform.resolveExecutable('codex'), codexHome: configuration.config }) + try { const value = await client.request(method, params); fixture.observations.push({ method, params, value, at: new Date().toISOString() }); return value } finally { await client.close() } + } + const userConfigRpc = async (method, params) => { + const client = await codexConnection(configuration.config, { executable: platform.resolveExecutable('codex'), codexHome: configuration.config }) + try { return await client.request(method, params) } finally { await client.close() } + } + fixture.ensureProjectTrust = async () => { + if (harness !== 'codex' || fixture.projectTrust) return + const file = join(configuration.config, 'config.toml'), before = (await userConfigRpc('config/read', { includeLayers: false })).config + if (before.projects?.[project]) fail('ACCEPTANCE_PROJECT_TRUST_ALREADY_PRESENT', project) + const bytes = existsSync(file) ? readFileSync(file) : null, backup = join(directory, 'codex-config-before.tar') + if (bytes) execFileSync(process.platform === 'win32' ? 'tar.exe' : 'tar', ['-cf', backup, file], { stdio: 'ignore' }) + fixture.projectTrust = { file, before, bytes, backup } + await userConfigRpc('config/batchWrite', { filePath: file, edits: [{ keyPath: `projects.${JSON.stringify(project)}.trust_level`, value: 'trusted', mergeStrategy: 'replace' }] }) + const after = (await userConfigRpc('config/read', { includeLayers: false })).config + assertOnlyProjectTrustChanged(before, after, project) + fixture.observations.push({ method: 'config/batchWrite', own_project: project, action: 'temporary_project_trust', others_semantically_unchanged: true, backup }) + } + fixture.removeProjectTrust = async () => { + if (!fixture.projectTrust) return + const baseline = fixture.projectTrust, current = (await userConfigRpc('config/read', { includeLayers: false })).config + if (current.projects?.[project]) { + await userConfigRpc('config/batchWrite', { filePath: baseline.file, edits: [{ keyPath: `projects.${JSON.stringify(project)}`, value: null, mergeStrategy: 'replace' }] }) + const after = (await userConfigRpc('config/read', { includeLayers: false })).config + assertOnlyProjectTrustChanged(current, after, project, { remove: true }) + } + const restored = (await userConfigRpc('config/read', { includeLayers: false })).config + if (restored.projects?.[project] || !isDeepStrictEqual(restored, baseline.before)) fail('ACCEPTANCE_PROJECT_TRUST_NOT_RESTORED', 'own trust entry撤去後に他設定の意味差が残っています') + const afterBytes = existsSync(baseline.file) ? readFileSync(baseline.file) : null + if (Boolean(afterBytes) !== Boolean(baseline.bytes) || afterBytes && !afterBytes.equals(baseline.bytes)) fail('ACCEPTANCE_PROJECT_TRUST_TOML_TEXT_CHANGED', `公式API撤去後にTOML本文差が残っています: ${baseline.file}`) + if (existsSync(baseline.backup)) rmSync(baseline.backup) + fixture.observations.push({ method: 'config/batchWrite', own_project: project, action: 'temporary_project_trust_removed', others_semantically_unchanged: true, toml_bytes_equal: true }) + fixture.projectTrust = null + } + fixture.modify = (file, mutate) => { + if (!contained(project, file)) fail('ACCEPTANCE_FIXTURE_WRITE_OUTSIDE', file) + if (existsSync(file) && realpathSync(file) !== file) fail('ACCEPTANCE_FIXTURE_LINK_WRITE', file) + if (!fixture.backups.has(file)) fixture.backups.set(file, existsSync(file) ? readFileSync(file) : null) + const value = existsSync(file) ? readFileSync(file) : null, next = mutate(value) + mkdirSync(dirname(file), { recursive: true, mode: 0o700 }) + if (next === null) rmSync(file); else writeFileSync(file, next, { mode: 0o600 }) + return { file, before_sha256: value && sha256(value), after_sha256: next && sha256(next) } + } + fixture.trustProjectHooks = async () => { + if (harness !== 'codex') return + await fixture.ensureProjectTrust() + const listed = await fixture.rpc('hooks/list', { cwds: [project] }), owned = listed.data.flatMap(group => group.hooks ?? []).filter(hook => contained(project, hook.sourcePath)) + if (!owned.length) fail('ACCEPTANCE_PROJECT_HOOK_NOT_DISCOVERED', '公式hooks/listが試験projectのhookを返しません') + const edits = owned.flatMap(hook => [{ keyPath: `hooks.state.${JSON.stringify(hook.key)}.trusted_hash`, value: hook.currentHash, mergeStrategy: 'replace' }, { keyPath: `hooks.state.${JSON.stringify(hook.key)}.enabled`, value: true, mergeStrategy: 'replace' }]) + const localConfig = join(project, '.codex/config.toml'); mkdirSync(dirname(localConfig), { recursive: true }) + if (!fixture.backups.has(localConfig)) fixture.backups.set(localConfig, existsSync(localConfig) ? readFileSync(localConfig) : null) + await fixture.rpc('config/batchWrite', { filePath: localConfig, edits }) + const after = (await fixture.rpc('hooks/list', { cwds: [project] })).data.flatMap(group => group.hooks ?? []).filter(hook => owned.some(item => item.key === hook.key)) + if (after.some(hook => !hook.enabled || hook.trustStatus !== 'trusted')) fail('ACCEPTANCE_PROJECT_HOOK_UNTRUSTED', JSON.stringify(after.map(hook => ({ sourcePath: hook.sourcePath, key: hook.key, trustStatus: hook.trustStatus, enabled: hook.enabled })))) + configuration.assertHooksUnchanged(); return after + } + fixture.setCodexOwnedHookFault = async fault => { + if (harness !== 'codex' || !['disabled', 'untrusted'].includes(fault)) fail('ACCEPTANCE_SESSION_HOOK_FAULT_UNCONFIRMED', `${harness}/${fault}`) + if (fixture.hookFault) { + const { local, original } = fixture.hookFault + if (original === null) rmSync(local, { force: true }); else writeFileSync(local, original, { mode: 0o600 }) + fixture.hookFault = null + } + const before = (await fixture.rpc('hooks/list', { cwds: [project] })).data.flatMap(group => group.hooks ?? []) + const command = platform.hookCommand(process.execPath, [join(pkg, 'skill/scripts/parent-hook.mjs'), 'codex']) + const owned = before.filter(hook => hook.command === command && realpathSync(hook.sourcePath) === realpathSync(configuration.hooks)) + if (owned.length !== 2) fail('ACCEPTANCE_GLOBAL_OWNED_HOOK_UNBOUND', '通常sourcePathの所有hook 2個を確定できません') + const local = join(project, '.codex/config.toml') + if (!fixture.backups.has(local)) fixture.modify(local, bytes => bytes ?? Buffer.from('')) + fixture.hookFault = { local, original: fixture.backups.get(local) } + const edits = [{ keyPath: 'features.hooks', value: fault !== 'disabled', mergeStrategy: 'replace' }] + if (fault === 'untrusted') for (const hook of owned) edits.push({ keyPath: `hooks.state.${JSON.stringify(hook.key)}.trusted_hash`, value: '0'.repeat(64) === hook.currentHash ? '1'.repeat(64) : '0'.repeat(64), mergeStrategy: 'replace' }, { keyPath: `hooks.state.${JSON.stringify(hook.key)}.enabled`, value: true, mergeStrategy: 'replace' }) + await fixture.rpc('config/batchWrite', { filePath: local, edits }) + const after = (await fixture.rpc('hooks/list', { cwds: [project] })).data.flatMap(group => group.hooks ?? []) + const affected = after.filter(hook => owned.some(entry => entry.key === hook.key)) + if (fault === 'disabled' ? affected.some(hook => hook.enabled !== false) : affected.length !== 2 || affected.some(hook => hook.trustStatus !== 'untrusted' || realpathSync(hook.sourcePath) !== realpathSync(configuration.hooks))) fail('ACCEPTANCE_SESSION_HOOK_FAULT_NOT_EFFECTIVE', `${fault}: 公式hooks/listで実効障害を確認できません`) + if (fault === 'untrusted' && !isDeepStrictEqual(before.filter(hook => !owned.some(entry => entry.key === hook.key)), after.filter(hook => !owned.some(entry => entry.key === hook.key)))) fail('ACCEPTANCE_FOREIGN_HOOK_EFFECTIVE_CHANGED', '専用project内でも非所有hookの状態を変更しました') + configuration.assertHooksUnchanged() + return { fault, local_config: local, before: owned, after: affected, official_method: 'hooks/list', global_sourcePath: configuration.hooks, normal_hook_bytes_equal: true } + } + fixture.addObserver = async ({ events, additionalArgs = [] } = {}) => { + const observer = join(project, 'acceptance-hook.mjs'), observations = join(directory, 'official-hook-events.jsonl'), controls = join(directory, 'hook-controls.json') + writeFileSync(observer, `import {readFileSync,appendFileSync,existsSync} from 'node:fs';let raw='';for await(const chunk of process.stdin)raw+=chunk;const event=JSON.parse(raw.replace(/^\\uFEFF/u,''));appendFileSync(process.argv[2],JSON.stringify({at:new Date().toISOString(),pid:process.pid,event})+'\\n');if(existsSync(process.argv[3])){const control=JSON.parse(readFileSync(process.argv[3],'utf8'));if(control.hold){while(!existsSync(control.release))await new Promise(r=>setTimeout(r,25));}}process.stdout.write('{}\\n');\n`, { mode: 0o600 }) + const command = platform.hookCommand(process.execPath, [observer, observations, controls, ...additionalArgs]) + if (!['claude', 'codex'].includes(harness) && !adapter.projectHookFile) fail('ACCEPTANCE_PROJECT_HOOK_PATH_UNCONFIRMED', `${harness}: 公式のproject hook配置が実測されていません`) + const file = harness === 'claude' ? join(project, '.claude/settings.local.json') : harness === 'codex' ? join(project, '.codex/hooks.json') : adapter.projectHookFile(project) + const nativeEvents = events ?? (harness === 'cursor' ? ['preToolUse', 'postToolUse', 'afterMCPExecution'] : ['PreToolUse', 'PostToolUse', 'Stop']) + fixture.modify(file, before => { + const value = before ? JSON.parse(before.toString('utf8').replace(/^\uFEFF/u, '')) : { ...(harness === 'cursor' ? { version: 1 } : {}), hooks: {} } + for (const event of nativeEvents) { const entry = harness === 'cursor' ? { command } : { hooks: [{ type: 'command', command, timeout: 86400 }] }; value.hooks[event] = [...(value.hooks[event] ?? []), entry] } + return Buffer.from(JSON.stringify(value, null, 2) + '\n') + }) + if (harness === 'codex') await fixture.trustProjectHooks() + fixture.observer = { observer, observations, controls, file, command } + return fixture.observer + } + fixture.addProductCompetitors = async ({ count = 2, compatibilityHarness } = {}) => { + if (!['claude', 'codex'].includes(harness) && !adapter.projectHookFile) fail('ACCEPTANCE_PROJECT_HOOK_PATH_UNCONFIRMED', harness) + const file = harness === 'claude' ? join(project, '.claude/settings.local.json') : harness === 'codex' ? join(project, '.codex/hooks.json') : adapter.projectHookFile(project) + const rows = join(directory, 'product-competitors.jsonl'), entry = join(project, 'product-competitor.mjs') + // stdinは公式hook eventそのもの。製品のstdout/stderrを無加工で同じ親へ返す。 + writeFileSync(entry, `import {spawn} from 'node:child_process';import {appendFileSync} from 'node:fs';let raw='';for await(const c of process.stdin)raw+=c;const e=JSON.parse(raw.replace(/^\\uFEFF/u,''));const log=x=>appendFileSync(process.argv[2],JSON.stringify({at:new Date().toISOString(),pid:process.pid,parent:process.ppid,event:e,...x})+'\\n');log({phase:'started'});const child=spawn(process.execPath,[process.argv[3],process.argv[4]],{stdio:['pipe','pipe','pipe']});let stdout='',stderr='';child.stdout.on('data',c=>{stdout+=c;process.stdout.write(c)});child.stderr.on('data',c=>{stderr+=c;process.stderr.write(c)});child.stdin.end(raw);child.on('exit',(code,signal)=>{log({phase:'completed',child_pid:child.pid,code,signal,stdout,stderr});process.exitCode=code??1});\n`, { mode: 0o600 }) + const commands = Array.from({ length: count }, (_, index) => platform.hookCommand(process.execPath, [entry, rows, join(pkg, 'skill/scripts/parent-hook.mjs'), compatibilityHarness ?? harness, String(index)])) + const events = harness === 'cursor' ? ['postToolUse', 'afterMCPExecution'] : ['PostToolUse', 'Stop'] + fixture.modify(file, before => { + const value = before ? JSON.parse(before.toString('utf8').replace(/^\uFEFF/u, '')) : { ...(harness === 'cursor' ? { version: 1 } : {}), hooks: {} } + for (const event of events) { + const handlers = commands.map(command => harness === 'cursor' ? { command } : { hooks: [{ type: 'command', command, ...(harness === 'claude' ? { asyncRewake: true } : {}), timeout: harness === 'claude' ? 86400 : 20, ...(harness === 'codex' ? { additionalContextLimit: 0 } : {}) }] }) + value.hooks[event] = [...(value.hooks[event] ?? []), ...handlers] + } + return Buffer.from(JSON.stringify(value, null, 2) + '\n') + }) + if (harness === 'codex') await fixture.trustProjectHooks() + fixture.competitors = { file, rows, entry, commands, count, compatibilityHarness }; return fixture.competitors + } + fixture.launch = async ({ resume } = {}) => { + const cli = adapter.resolveCli(), launcher = join(directory, 'launcher.mjs'), input = join(directory, 'launch.json') + const argv = harness === 'codex' ? ['-c', 'mcp_servers.peertable_parent.env_vars=["PEERTABLE_TOKEN_SOURCE_FILE"]', '-c', 'check_for_update_on_startup=false'] : harness === 'claude' ? [] : adapter.argv?.({ project, tokenFile }) ?? [] + if (sessionSettings && harness === 'claude') { const settings = join(project, 'session-settings.json'); writeJson(settings, sessionSettings); argv.push('--settings', settings) } + if (model) argv.push(harness === 'codex' ? '-m' : '--model', model) + if (resume) argv.unshift(...(harness === 'codex' ? ['resume', resume] : harness === 'claude' ? ['--resume', resume] : adapter.resumeArgs(resume))) + writeJson(input, { executable: cli.executable, argv, project, tokenFile }) + // HOME・認証・model/provider設定はharnessの通常環境のまま。token参照だけを試験roomへ渡す。 + writeFileSync(launcher, `import {readFileSync} from 'node:fs';import {spawn} from 'node:child_process';const p=JSON.parse(readFileSync(process.argv[2],'utf8'));const child=spawn(p.executable,p.argv,{cwd:p.project,stdio:'inherit',env:{...process.env,PEERTABLE_TOKEN_SOURCE_FILE:p.tokenFile},shell:process.platform==='win32'});child.on('exit',(code)=>process.exit(code??1));\n`, { mode: 0o600 }) + if (!fixture.aiterm) fixture.aiterm = await openAiterm() + if (!fixture.pty) fixture.pty = await fixture.aiterm.open(`pt-fixture-${randomUUID().slice(0, 8)}`, process.platform === 'win32' ? 'pwsh' : undefined) + await fixture.trackOwnProcesses() + await fixture.aiterm.send(fixture.pty, platform.shellCommand(process.execPath, [launcher, input])) + await until('通常HOMEの公式CLI起動', async () => { await fixture.trackOwnProcesses(); const action = adapter.startup(await fixture.aiterm.screen(fixture.pty)); if (action?.blocked) fail('ACCEPTANCE_FIXTURE_HARNESS_BLOCKED', action.blocked); if (action?.keys) { for (const key of action.keys) { await fixture.aiterm.key(fixture.pty, key); await sleep(500) } return false } return action?.ready }, 120000, 1500) + fixture.ready = true + if (adapter.afterStartup) await adapter.afterStartup() + return fixture + } + fixture.submit = async text => { await fixture.aiterm.send(fixture.pty, text, false); await sleep(1500); await fixture.aiterm.key(fixture.pty, 'Enter') } + fixture.refreshTarget = async (previousEndpoint = null) => { + const spool = await until('fixture parent_join', async () => { await fixture.trackOwnProcesses(); return projectEndpoints(project).find(item => item.id !== previousEndpoint && item.read().name === name && item.read().runtime !== 'stopped') }) + const joined = await until('fixture verified', async () => { await fixture.trackOwnProcesses(); const state = spool.read(); if (state.state === 'failed') fail(state.error_code, 'fixtureのjoinが失敗しました'); return state.state === 'verified' ? state : null }) + fixture.session = joined.caller.conversation; fixture.owner = joined.caller.owner + const file = await until('fixture公式transcript', () => adapter.transcript(fixture.session)) + const meta = { ...sourceMeta, harness, room, parent_session: fixture.session, parent_process: fixture.owner, endpoint_id: spool.id, harness_version: adapter.resolveCli().version } + const sessionAtJoin = fixture.session + const boundObserve = () => { const seen = adapter.read(file, { session: sessionAtJoin, spool }); return seen.deliveries ? seen : { ...seen, deliveries: seen.injections.flatMap(row => parseDelivered(row.text).map(delivery => ({ ...delivery, order: row.order, session: row.session ?? sessionAtJoin, turn_id: row.turn_id, hook: row.hook, boundary: { encoding: row.encoding ?? 'none' } }))), replies: seen.replies.map(reply => ({ ...reply, session: reply.session ?? sessionAtJoin })) } } + fixture.target = { meta, spool, api: apiFor(room), observe: boundObserve, submit: fixture.submit, file, screen: () => fixture.aiterm.screen(fixture.pty), fixture } + return fixture.target + } + fixture.join = async (previousEndpoint = null) => { await fixture.submit(`Peertable parent_joinをproject=${project} name=${name}で1回呼んでください。同じ会話のroom配送の確認符号を原文のまま報告し、受信を継続してください。Cursor/Grokの背景登録とparent_readは製品receiptの完成済み入力を公式toolへ渡してください。`); return fixture.refreshTarget(previousEndpoint) } + fixture.restore = () => { for (const [file, original] of fixture.backups) { if (original === null) rmSync(file, { force: true }); else { writeFileSync(file, original, { mode: 0o600 }); if (!readFileSync(file).equals(original)) fail('ACCEPTANCE_FIXTURE_RESTORE_FAILED', file) } }; fixture.backups.clear(); configuration.assertHooksUnchanged() } + fixture.stop = async () => { + await fixture.trackOwnProcesses() + if (!fixture.ready || !fixture.owner || !platform.sameProcess(fixture.owner)) return + const exit = harness === 'claude' ? '/exit' : harness === 'codex' ? '/quit' : adapter.exit + if (!exit) return + await fixture.submit(exit) + await until('fixture親終了', () => !platform.sameProcess(fixture.owner), 30000) + } + fixture.close = async () => { + if (fixture.closed) return + const errors = [] + try { fixture.restore() } catch (error) { errors.push(error) } + try { await fixture.stop() } catch (error) { errors.push(error) } + // 起動dialog失敗・joinなしでも、own paneを公式APIで閉じ、既知のnative親を終了させる。 + if (fixture.pty) { + try { await fixture.trackOwnProcesses(); await fixture.aiterm.close(fixture.pty); fixture.pty = null } catch (error) { errors.push(error) } + } + try { + await waitOwnedFixtureExit({ readEndpoints: () => projectEndpoints(project), sameProcess: platform.sameProcess, knownOwners: [...fixture.nativeOwners, ...fixture.receiverOwners, fixture.paneOwner].filter(Boolean), indexExists: id => existsSync(join(homedir(), '.peertable/parent-receivers/endpoints', `${id}.json`)), timeout: 30000 }) + } catch (error) { errors.push(error) } + if (fixture.aiterm) { try { await fixture.aiterm.end(); fixture.aiterm = null } catch (error) { errors.push(error) } } + try { await fixture.removeProjectTrust() } catch (error) { errors.push(error) } + try { configuration.assertHooksUnchanged() } catch (error) { errors.push(error) } + if (errors.length) fail('ACCEPTANCE_FIXTURE_CLEANUP_FAILED', JSON.stringify(errors.map(error => ({ code: error.code, message: error.message })))) + fixture.closed = true; fixtures.delete(fixture) + } + fixtures.add(fixture) + await fixture.ensureProjectTrust() + if (adapter.prepare) await adapter.prepare() + if (prepare) await prepare(fixture) + await fixture.launch(); if (initialJoin) await fixture.join(); return fixture + }, + async close() { const errors = []; for (const fixture of [...fixtures].reverse()) { try { await fixture.close() } catch (error) { errors.push({ code: error.code, message: error.message, directory: fixture.directory }) } } if (errors.length) fail('ACCEPTANCE_FIXTURE_CLEANUP_FAILED', JSON.stringify(errors)) }, + } +} diff --git a/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs b/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs new file mode 100644 index 00000000..11aff087 --- /dev/null +++ b/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs @@ -0,0 +1,53 @@ +// fixture終了と設定読取りのfocused test。product_liveの証拠には用いない。 +import test from 'node:test' +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { assertOnlyProjectTrustChanged, hookConfigurationSnapshot, waitOwnedFixtureExit } from './scenarios-fixtures.mjs' + +test('fixture終了は実child消失の後も製品自己停止と索引撤去を待つ', async t => { + const child = spawn(process.execPath, ['-e', 'setTimeout(()=>process.exit(0),150)'], { stdio: 'ignore' }) + t.after(() => { if (child.exitCode === null) child.kill('SIGKILL') }) + let runtime = 'armed', indexed = true + const stopped = setTimeout(() => { runtime = 'stopped' }, 300), removed = setTimeout(() => { indexed = false }, 450) + t.after(() => { clearTimeout(stopped); clearTimeout(removed) }) + const started = Date.now() + const proof = await waitOwnedFixtureExit({ readEndpoints: () => [{ id: 'own', read: () => ({ runtime, watcher: { pid: child.pid } }) }], sameProcess: owner => owner.pid === child.pid && child.exitCode === null, knownOwners: [{ pid: child.pid }], indexExists: () => indexed, timeout: 2000 }) + assert.ok(Date.now() - started >= 400) + assert.equal(proof.known_processes_gone, true); assert.equal(proof.indexes_gone, true) +}) + +test('join前の失敗でも既知pane/native childが消えるまでcleanup成功にしない', async t => { + const child = spawn(process.execPath, ['-e', 'setTimeout(()=>process.exit(0),250)'], { stdio: 'ignore' }) + t.after(() => { if (child.exitCode === null) child.kill('SIGKILL') }) + const started = Date.now() + await waitOwnedFixtureExit({ readEndpoints: () => [], sameProcess: owner => owner.pid === child.pid && child.exitCode === null, knownOwners: [{ pid: child.pid }], indexExists: () => false, timeout: 2000 }) + assert.ok(Date.now() - started >= 200); assert.equal(child.exitCode, 0) + await assert.rejects(waitOwnedFixtureExit({ readEndpoints: () => [], sameProcess: () => true, knownOwners: [{ pid: 123 }], indexExists: () => false, timeout: 1 }), { code: 'ACCEPTANCE_FIXTURE_TIMEOUT' }) +}) + +test('BOM付き通常hook設定を読み、元bytesを変更せず照合する', t => { + const dir = mkdtempSync(join(tmpdir(), 'peertable-scenarios-bom-')); t.after(() => rmSync(dir, { recursive: true, force: true })) + const file = join(dir, 'hooks.json'), bytes = Buffer.from('\uFEFF{\r\n "version": 1, "hooks": {}\r\n}\r\n') + writeFileSync(file, bytes) + const snapshot = hookConfigurationSnapshot(file) + assert.equal(snapshot.beforeHooks.version, 1); snapshot.assertHooksUnchanged(); assert.deepEqual(readFileSync(file), bytes) + writeFileSync(file, bytes.toString('utf8').slice(1)) + assert.throws(() => snapshot.assertHooksUnchanged(), { code: 'ACCEPTANCE_USER_HOOK_CONFIG_CHANGED' }) +}) + + +test('専用project trustの1 entryだけを許し、他設定の意味差と既存entry上書きを拒否する', () => { + const project = '/専用 fixture/new', before = { model: 'gpt-6-sol', projects: { '/既存': { trust_level: 'trusted' } }, hooks: { state: { foreign: { enabled: false } } } } + const after = structuredClone(before); after.projects[project] = { trust_level: 'trusted' } + assertOnlyProjectTrustChanged(before, after, project) + assertOnlyProjectTrustChanged(after, before, project, { remove: true }) + const changed = structuredClone(after); changed.hooks.state.foreign.enabled = true + assert.throws(() => assertOnlyProjectTrustChanged(before, changed, project), { code: 'ACCEPTANCE_PROJECT_TRUST_OTHER_SETTINGS_CHANGED' }) + assert.throws(() => assertOnlyProjectTrustChanged(after, after, project), { code: 'ACCEPTANCE_PROJECT_TRUST_ALREADY_PRESENT' }) + assert.throws(() => assertOnlyProjectTrustChanged(before, before, project, { remove: true }), { code: 'ACCEPTANCE_PROJECT_TRUST_BASELINE_MISSING' }) + assertOnlyProjectTrustChanged({}, { projects: { [project]: { trust_level: 'trusted' } } }, project) + assertOnlyProjectTrustChanged({ projects: { [project]: { trust_level: 'trusted' } } }, {}, project, { remove: true }) +}) diff --git a/experiments/parent-product-acceptance/scenarios-lease-run.mjs b/experiments/parent-product-acceptance/scenarios-lease-run.mjs new file mode 100644 index 00000000..a696860e --- /dev/null +++ b/experiments/parent-product-acceptance/scenarios-lease-run.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node +// lease単独の長時間run。共通導入物/通常認証を使い、短い受入runとはroom/processを分ける。 +import { mkdirSync, readFileSync, writeFileSync, appendFileSync, readdirSync } from 'node:fs' +import { join, resolve, dirname } from 'node:path' +import { spawn, execFileSync } from 'node:child_process' +import { randomUUID, createHash } from 'node:crypto' +import { createServer } from 'node:net' +import { pathToFileURL, fileURLToPath } from 'node:url' +import { createNativeScenarioContext } from './scenarios-native.mjs' +import { runScenario } from './scenarios.mjs' +import { buildCase, buildRecord, selfAudit, acceptCase, project } from './evidence.mjs' + +const fail = (code, message) => { throw Object.assign(new Error(message), { code }) } +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) +export async function runLeaseOnly(options) { + const { pkg, out, harness, sourceCommit, sourceDigest, version, tarballSha256, model } = options + if (!['claude', 'codex', 'cursor', 'grok'].includes(harness) || !/^[0-9a-f]{40}$/u.test(sourceCommit ?? '') || !/^[0-9a-f]{64}$/u.test(tarballSha256 ?? '')) fail('ACCEPTANCE_LEASE_PROVENANCE_REQUIRED', 'harness/source commit/pack tarballのSHAが必要です') + const runnerDirectory = dirname(fileURLToPath(import.meta.url)), runnerCommit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: runnerDirectory, encoding: 'utf8' }).trim() + const runnerModules = Object.fromEntries(readdirSync(runnerDirectory).filter(name => name.endsWith('.mjs') && !name.endsWith('.test.mjs')).sort().map(name => [name, createHash('sha256').update(readFileSync(join(runnerDirectory, name))).digest('hex')])) + const runnerProvenance = { commit: runnerCommit, modules_sha256: runnerModules, product_source_is_separate: true } + const installedVersion = JSON.parse(readFileSync(join(pkg, 'package.json'), 'utf8')).version + const { runtimeDigest } = await import(pathToFileURL(join(pkg, 'skill/scripts/runtime-digest.mjs')).href) + const digest = runtimeDigest() + if (installedVersion !== version || digest !== sourceDigest) fail('ACCEPTANCE_LEASE_INSTALLED_SOURCE_MISMATCH', '共通導入物のversion/runtime digestがfreezeと一致しません') + mkdirSync(out, { recursive: true, mode: 0o700 }) + const privateDir = join(out, 'private'); mkdirSync(privateDir, { recursive: true, mode: 0o700 }) + const logFile = join(privateDir, 'lease-progress.jsonl'), summaryFile = join(out, 'summary.json') + const summary = { schema: 'peertable.lease-run.v1', runner: runnerProvenance, status: 'running', harness, os: process.platform, source_commit: sourceCommit, runtime_digest: digest, package_version: installedVersion, started_at: new Date().toISOString(), current_boundary: 'prepare', cases: [] } + const log = (kind, fields = {}) => { const row = { at: new Date().toISOString(), kind, ...fields }; appendFileSync(logFile, JSON.stringify(project(row)) + '\n'); summary.current_boundary = kind; writeFileSync(summaryFile, JSON.stringify(project(summary), null, 2)); process.stdout.write(JSON.stringify(project(row)) + '\n') } + const port = await new Promise(resolvePort => { const socket = createServer(); socket.listen(0, '127.0.0.1', () => { const chosen = socket.address().port; socket.close(() => resolvePort(chosen)) }) }) + const token = randomUUID(), tokenFile = join(privateDir, 'room.env'); writeFileSync(tokenFile, `PEERTABLE_POST_TOKEN=${token}\n`, { mode: 0o600 }) + const serverUrl = `http://127.0.0.1:${port}`, room = `parent-lease-${harness}-${randomUUID()}` + const server = spawn(process.execPath, [join(pkg, 'room/server.mjs')], { cwd: out, env: { ...process.env, PEERTABLE_PORT: String(port), PEERTABLE_DATA: join(privateDir, 'room-data'), PEERTABLE_POST_TOKEN: token }, stdio: ['ignore', 'ignore', 'pipe'] }) + let stderr = ''; server.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-4000) }) + const apiFor = name => async (path, body) => { const response = await fetch(`${serverUrl}/api/${encodeURIComponent(name)}/${path}`, { method: body ? 'POST' : 'GET', headers: { 'Content-Type': 'application/json', 'X-Peertable-Token': token }, body: body ? JSON.stringify(body) : undefined }); if (!response.ok) fail('ACCEPTANCE_LEASE_HTTP', `${path}:${response.status}`); return response.json() } + const sourceMeta = { os: process.platform, harness, surface: 'cli', source_commit: sourceCommit, runtime_digest: digest, package_version: installedVersion, client_version: installedVersion, package_tarball_sha256: tarballSha256, node_version: process.version, runner: runnerProvenance, room, isolation: { home: 'normal', auth: 'normal', install: '共通npm pack導入物', room: 'lease専用server', fixture: '公式通常CLIの専用project/process' } } + const errors = []; let context + try { + const end = Date.now() + 15000 + while (!(await fetch(`${serverUrl}/api/rooms`).then(response => response.ok, () => false))) { if (server.exitCode !== null || Date.now() >= end) fail('ACCEPTANCE_LEASE_ROOM_START', stderr); await sleep(200) } + await apiFor(room)('members', { name: 'probe', harness: 'codex' }) + log('prepare', { room, installed_root: pkg }) + context = await createNativeScenarioContext('lease', { pkg, out: privateDir, tokenFile, serverUrl, sourceMeta, apiFor, model }) + log('native_parent_verified', { parent_session: context.session, parent_process: context.caseMeta.parent_process, endpoint_id: context.caseMeta.endpoint_id }) + for (const [name, action] of Object.entries(context.actions)) context.actions[name] = async (input, scope) => { log(`${name}:started`); const result = await action(input, scope); log(`${name}:observed`, { expectation: result.expectation }); return result } + const measured = await runScenario('lease', context) + const evidence = buildCase({ meta: context.caseMeta, scenario: 'lease', checks: measured.checks, observations: measured.observations, extra: { trace: measured.trace, run_id: measured.run_id, boundary_contract: measured.boundaries } }) + const evidenceFile = `rag/parent-delivery/live/${evidence.case_id}.json`, file = join(out, 'public', evidenceFile); mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, JSON.stringify(evidence, null, 2)) + const record = buildRecord(evidence, evidenceFile), audit = selfAudit(record, evidence); acceptCase(record, audit) + summary.cases.push({ ...record, gate_errors: audit.gate_errors }); writeFileSync(join(out, 'records.json'), JSON.stringify({ records: [record] }, null, 2)) + summary.status = 'passed'; log('lease_completed') + } catch (error) { errors.push({ code: error.code ?? 'ACCEPTANCE_LEASE_RUN_FAILED', message: error.message, detail: error.detail }); summary.status = 'failed'; log('lease_failed', errors.at(-1)) } + finally { + if (server.exitCode === null && server.signalCode === null) { + const exited = new Promise(resolveExit => server.once('exit', resolveExit)); server.kill('SIGTERM'); await Promise.race([exited, sleep(10000)]) + if (server.exitCode === null && server.signalCode === null) { errors.push({ code: 'ACCEPTANCE_LEASE_ROOM_ALIVE' }); summary.status = 'failed' } + } + summary.errors = errors; summary.finished_at = new Date().toISOString(); writeFileSync(summaryFile, JSON.stringify(project(summary), null, 2)) + } + return summary +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const flags = {}; const args = process.argv.slice(2) + for (let index = 0; index < args.length; index += 2) { const flag = args[index], value = args[index + 1]; if (!/^--(?:harness|pkg|out|source-commit|digest|version|tarball-sha256|model)$/u.test(flag) || !value || value.startsWith('--')) fail('ACCEPTANCE_LEASE_ARGUMENT', flag); flags[flag.slice(2)] = value } + for (const name of ['pkg', 'out']) { if (!flags[name]) fail('ACCEPTANCE_LEASE_ARGUMENT', `--${name}`); flags[name] = resolve(flags[name]) } + const result = await runLeaseOnly({ ...flags, sourceCommit: flags['source-commit'], sourceDigest: flags.digest, tarballSha256: flags['tarball-sha256'] }) + if (result.status !== 'passed') process.exitCode = 1 +} diff --git a/experiments/parent-product-acceptance/scenarios-native.mjs b/experiments/parent-product-acceptance/scenarios-native.mjs new file mode 100644 index 00000000..a925d37a --- /dev/null +++ b/experiments/parent-product-acceptance/scenarios-native.mjs @@ -0,0 +1,574 @@ +// 公式CLI・公式queue・専用project hookで追加scenarioを動かす。fixtureの記録だけでは合格にしない。 +import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { randomUUID } from 'node:crypto' +import { isDeepStrictEqual } from 'node:util' +import { execFileSync } from 'node:child_process' +import { createScenarioContext } from './scenarios-context.mjs' +import { createNativeFixtureFactory, waitOwnedFixtureExit } from './scenarios-fixtures.mjs' +import { sha256 } from './evidence.mjs' +import { createBackgroundSurfaceAdapters } from './scenarios-surfaces.mjs' + +const fail = (code, message, detail) => { throw Object.assign(new Error(message), { code, detail }) } +const rows = file => existsSync(file) ? readFileSync(file, 'utf8').split('\n').filter(Boolean).map(line => JSON.parse(line)) : [] +const nativeSession = event => event.session_id ?? event.conversation_id ?? event.sessionId +const nativeTurn = event => event.turn_id ?? event.prompt_id ?? event.tool_use_id ?? event.toolUseId +const hookOutput = row => Boolean(row.stdout?.trim() && row.stdout.trim() !== '{}') || Boolean(row.stderr?.trim()) +const sourceUnconfirmed = (harness, boundary, detail) => fail('ACCEPTANCE_OFFICIAL_BOUNDARY_UNCONFIRMED', `${harness}: ${boundary}の公式境界を確認できません`, detail) + +const processTree = () => { + if (process.platform === 'win32') { + const parsed = JSON.parse(execFileSync('pwsh.exe', ['-NoProfile', '-NonInteractive', '-Command', 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId | ConvertTo-Json -Compress'], { encoding: 'utf8' })) + return (Array.isArray(parsed) ? parsed : [parsed]).map(row => ({ pid: row.ProcessId, parent: row.ParentProcessId })) + } + return execFileSync('/bin/ps', ['-A', '-o', 'pid=', '-o', 'ppid='], { encoding: 'utf8' }).trim().split('\n').map(line => { const [pid, parent] = line.trim().split(/\s+/u).map(Number); return { pid, parent } }) +} + +// 製品watcherから起動した公式app-serverだけ。runnerの別RPC接続を更新証拠へ混ぜない。 +export function monitorQueueConnections({ watcher, executable, processIdentity, sameProcess, artifact, interval = process.platform === 'win32' ? 100 : 20 }) { + const monitor = { canceled: false, connections: [], error: null } + monitor.promise = (async () => { + while (!monitor.canceled) { + if (!sameProcess(watcher)) fail('ACCEPTANCE_QUEUE_WATCHER_GONE', '接続観測中に専用watcherが終了しました') + const tree = processTree(), descendants = new Set([watcher.pid]); let added = true + while (added) { added = false; for (const row of tree) if (descendants.has(row.parent) && !descendants.has(row.pid)) { descendants.add(row.pid); added = true } } + for (const pid of descendants) { + if (pid === watcher.pid) continue + const previous = monitor.connections.find(connection => connection.owner.pid === pid && !connection.closed_at) + if (previous) { if (sameProcess(previous.owner)) previous.last_seen_at = new Date().toISOString(); else previous.closed_at = new Date().toISOString(); continue } + const owner = processIdentity(pid) + if (!owner || owner.executable !== executable || !/app-server\s+--listen\s+stdio:\/\//u.test(owner.command)) continue + let current = monitor.connections.find(connection => connection.owner.pid === pid && connection.owner.started === owner.started) + if (!current) { current = { owner, watcher, first_seen_at: new Date().toISOString(), last_seen_at: null, closed_at: null }; monitor.connections.push(current) } + current.last_seen_at = new Date().toISOString() + } + for (const connection of monitor.connections) if (!connection.closed_at && !sameProcess(connection.owner)) connection.closed_at = new Date().toISOString() + if (artifact) writeFileSync(artifact, JSON.stringify({ source: 'os_process', connections: monitor.connections }, null, 2)) + await new Promise(resolve => setTimeout(resolve, interval)) + } + })().catch(error => { monitor.error = error }) + monitor.close = async () => { monitor.canceled = true; await monitor.promise; if (monitor.error) throw monitor.error } + return monitor +} + +export function queueConnectionProof(connections, checks) { + const bound = checks.map(check => { + if (!check.receipt.queued_submission_id || !check.receipt.accepted_at) fail('ACCEPTANCE_QUEUE_ACCEPTANCE_MISSING', '公式queue受付がありません') + const at = Date.parse(check.receipt.accepted_at) + const candidates = connections.filter(connection => Date.parse(connection.first_seen_at) <= at && connection.closed_at && Date.parse(connection.closed_at) >= at) + if (candidates.length !== 1) fail('ACCEPTANCE_QUEUE_CONNECTION_UNBOUND', '公式受付時点の存命app-serverが1個に確定しません') + return { seq: check.original.seq, queued_submission_id: check.receipt.queued_submission_id, accepted_at: check.receipt.accepted_at, connection: candidates[0] } + }) + if (bound.length < 2 || new Set(bound.map(item => `${item.connection.owner.pid}:${item.connection.owner.started}`)).size < 2) fail('ACCEPTANCE_QUEUE_CONNECTION_NOT_UPDATED', '複数配送で新しい公式接続を確認できません') + return bound +} + +// 公式MCP包装のJSONだけを開き、本文の正規化や自由文からのerror推測をしない。 +export function productToolError(value, expected) { + if (typeof value === 'string') { try { return productToolError(JSON.parse(value), expected) } catch (error) { if (error instanceof SyntaxError) return null; throw error } } + if (!value || typeof value !== 'object') return null + if (value.schema === 'peertable.parent-error.v1') return value.state === 'failed' && value.error_code === expected ? value : null + if (value.structuredContent) return productToolError(value.structuredContent, expected) + for (const part of value.content ?? []) if (part.type === 'text') { const found = productToolError(part.text, expected); if (found) return found } + return null +} + +// callbackで判定を置換しない。実hookの同eventを実process 2個以上が受け、本文出力が1個であることを照合する。 +export function competitorProof(events, { session, delivery, minimum = 2, compatibility = false }) { + const groups = new Map() + for (const row of events) { + if (row.phase !== 'started' || nativeSession(row.event) !== session || row.event.turn_id && row.event.turn_id !== delivery.turn_id) continue + const key = sha256(JSON.stringify(row.event)) + if (!groups.has(key)) groups.set(key, []) + groups.get(key).push(row) + } + const group = [...groups.values()].find(starts => new Set(starts.map(row => row.pid)).size >= minimum && starts.every(start => events.some(row => row.phase === 'completed' && row.pid === start.pid && isDeepStrictEqual(row.event, start.event)))) + if (!group) fail('ACCEPTANCE_HOOK_COMPETITION_NOT_OBSERVED', '同じ実eventの競合processと完了が揃いません') + const started = group + const completed = events.filter(row => row.phase === 'completed' && started.some(start => start.pid === row.pid && isDeepStrictEqual(start.event, row.event))) + if (new Set(completed.map(row => row.pid)).size !== completed.length || completed.length !== started.length) fail('ACCEPTANCE_HOOK_COMPETITION_NOT_OBSERVED', '同じprocessの完了重複があります') + + if (compatibility) { + if (completed.some(row => row.code !== 0 || hookOutput(row))) fail('ACCEPTANCE_COMPATIBILITY_DUPLICATE', '別harnessの互換hookが相関または本文を出力しました') + } else if (completed.filter(hookOutput).length > 1) fail('ACCEPTANCE_CLAIM_OUTPUT_DUPLICATED', '競合hookが複数の本文を出力しました') + return { starts: started, completions: completed, competitor_outputs: completed.filter(hookOutput).length } +} + +export function verifyRelatedIsolation({ primary, secondary, checks, messages }) { + if (primary.meta.parent_session === secondary.meta.parent_session || primary.spool.id === secondary.spool.id || primary.meta.room === secondary.meta.room) fail('ACCEPTANCE_PARALLEL_IDENTITY', 'room・会話・endpointが独立していません') + for (const check of checks) { + const target = check.parent_session === primary.meta.parent_session ? primary : secondary + const other = target === primary ? secondary : primary + if (check.original.room !== target.meta.room || !messages[target.meta.room].some(message => message.seq === check.original.seq && message.body === check.original.body)) fail('ACCEPTANCE_PARALLEL_SOURCE', 'roomの原文が不一致です') + if (other.observe().deliveries.some(delivery => delivery.delivery_id === check.delivery.delivery_id || delivery.body === check.original.body)) fail('ACCEPTANCE_PARALLEL_CROSS_DELIVERY', '別room/会話へ誤流入しました') + } + return { primary: { room: primary.meta.room, session: primary.meta.parent_session, endpoint: primary.spool.id }, secondary: { room: secondary.meta.room, session: secondary.meta.parent_session, endpoint: secondary.spool.id } } +} + +// OS適合はここだけ。対象PID+開始identityを毎回照合し、native親自身や共有受信を停止しない。 +export async function pauseOwnedReceiver(owner, { parent, sameProcess, processDescendsFrom, paused, resume = false }) { + if (!owner || !sameProcess(owner) || owner.pid === parent.pid || !processDescendsFrom(owner, parent)) fail('ACCEPTANCE_PROCESS_FAULT_OWNER', '専用親の受信processではありません') + if (process.platform !== 'win32') process.kill(owner.pid, resume ? 'SIGCONT' : 'SIGSTOP') + else { + // Windows公式thread API。列挙するのは検証済みown PIDのthreadだけ。 + const script = `Add-Type -TypeDefinition 'using System;using System.Runtime.InteropServices;public class PeertableOwnThreads{[DllImport("kernel32.dll")]public static extern IntPtr OpenThread(uint a,bool b,uint c);[DllImport("kernel32.dll")]public static extern uint SuspendThread(IntPtr h);[DllImport("kernel32.dll")]public static extern uint ResumeThread(IntPtr h);[DllImport("kernel32.dll")]public static extern bool CloseHandle(IntPtr h);}';$p=Get-Process -Id ${owner.pid};foreach($t in $p.Threads){$h=[PeertableOwnThreads]::OpenThread(2,$false,$t.Id);if($h -eq [IntPtr]::Zero){throw 'own thread open failed'};try{$r=[PeertableOwnThreads]::${resume ? 'ResumeThread' : 'SuspendThread'}($h);if($r -eq 4294967295){throw 'own thread control failed'}}finally{[void][PeertableOwnThreads]::CloseHandle($h)}}` + execFileSync('pwsh.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { stdio: 'pipe' }) + } + if (resume) paused.delete(owner.pid); else paused.set(owner.pid, owner) + if (!sameProcess(owner)) fail('ACCEPTANCE_PROCESS_FAULT_DISAPPEARED', '一時停止/再開の対象が終了しました') + return { owner, operation: resume ? 'resume' : 'suspend', at: new Date().toISOString() } +} + +// 残る境界を呼出し側の任意callbackへ押し出さず、このmodule内の公式操作に固定する。 +export function createNativeActions({ factory, primary, lifecycle = null }) { + const saved = new Map(), paused = new Map(), ownedFixtures = new Set([primary]) + const self = s => s.context.endpoint('self') + const fixtureFor = target => { if (!target.fixture || !ownedFixtures.has(target.fixture)) fail('ACCEPTANCE_FIXTURE_OWNER_MISSING', 'scenario専用親であることを確認できません'); return target.fixture } + const state = s => saved.get(s.runId) ?? (() => { const value = {}; saved.set(s.runId, value); return value })() + const observerRows = fixture => rows(fixture.observer?.observations) + const hookObservation = (s, expectation, detail) => s.result(s, expectation, 'official_hook', detail) + const join = async (s, alias = 'self') => { const target = s.context.endpoint(alias), fixture = fixtureFor(target); await fixture.join(); return fixture.target } + const recordPending = s => s.pending.filter(item => item.scope.runId === s.runId && item.scope.scenario === s.scenario) + const confirmOne = s => { const last = s.checks.at(-1); if (!last || last.count !== 1 || last.status !== 'passed') fail('ACCEPTANCE_NATIVE_DELIVERY_MISSING', s.scenario); return last } + const actions = {} + + if (['cursor', 'grok'].includes(primary.harness)) { + actions.work_start = async (_, s) => { + const target = self(s), fixture = fixtureFor(target) + const script = s.artifactFor(s, 'work-process.mjs'), identity = s.artifactFor(s, 'work-start.json'), release = s.artifactFor(s, 'work-release.json'), completed = s.artifactFor(s, 'work-completed.json'), nonce = `PEERTABLE_WORK_${randomUUID()}` + writeFileSync(script, `import {writeFileSync,existsSync} from 'node:fs';writeFileSync(process.argv[2],JSON.stringify({pid:process.pid}));while(!existsSync(process.argv[3]))await new Promise(r=>setTimeout(r,100));writeFileSync(process.argv[4],JSON.stringify({pid:process.pid}));console.log(process.argv[5]);\n`) + const command = s.shellCommand(process.execPath, [script, identity, release, completed, nonce]), before = target.observe().rows + state(s).work = { fixture, identity, release, completed, nonce, before } + const input = s.harness === 'cursor' ? { command, cwd: fixture.project } : { command, description: '配送中も継続する専用受入作業', background: true, timeout: 0 } + await fixture.submit(`作業継続の実機受入です。${s.harness === 'cursor' ? 'Shell' : 'run_terminal_command'}へ次の完成済み入力を渡してください。専用processを停止せず、room符号の報告後も作業を維持してください。runnerが解除した後に公式taskの全量出力を読んで、完了符号${nonce}を報告してください。input: ${JSON.stringify(input)}`) + const observed = await s.until('専用作業processと公式taskの相関', () => { + if (!existsSync(identity)) return null + const owner = s.processIdentity(JSON.parse(readFileSync(identity, 'utf8')).pid), seen = target.observe() + if (!owner || !s.sameProcess(owner) || !s.processDescendsFrom(owner, target.meta.parent_process) || existsSync(completed)) fail('ACCEPTANCE_WORK_NATIVE_OWNER', '専用親の存命作業processではありません') + const use = seen.toolUses.find(use => use.order >= before && use.name === (s.harness === 'cursor' ? 'Shell' : 'run_terminal_command') && isDeepStrictEqual(use.input, input)) + if (!use?.turn_id) return null + const task = (seen.tasks ?? []).find(task => task.tool_use_id === use.id && task.pid && (task.pid === owner.pid || s.processDescendsFrom(owner, s.processIdentity(task.pid)))) + return task ? { owner, use, task } : null + }, 120000, 100) + Object.assign(state(s).work, observed) + return s.result(s, 'work_running', 'native_task', { work_process: observed.owner, work_native_task: observed.task, work_tool_use_id: observed.use.id, work_turn_id: observed.use.turn_id, work_input: input }) + } + actions.work_finish = async (_, s) => { + const work = state(s).work, target = self(s), check = confirmOne(s) + if (!work?.owner || !s.sameProcess(work.owner) || existsSync(work.completed) || check.delivery.order < work.before) fail('ACCEPTANCE_WORK_NOT_CONTINUED', '配送中に元作業を継続していません') + const held = s.processIdentity(work.owner.pid) + writeFileSync(work.release, '{}') + await s.until('元作業processの正常終了', () => existsSync(work.completed) && !s.sameProcess(work.owner), 30000, 100) + const completion = await s.until('元taskの公式全量readerと完了応答', () => { + const seen = target.observe(), taskId = String(work.task.id ?? work.task.task_id) + const reader = seen.toolUses.find(use => use.order > check.delivery.order && (s.harness === 'cursor' ? use.name === 'Read' && use.input?.path === work.fixture.adapter.nativeTaskOutputPath(taskId) && !Object.hasOwn(use.input, 'offset') && !Object.hasOwn(use.input, 'limit') : use.name === 'get_command_or_subagent_output' && isDeepStrictEqual(use.input, { task_ids: [taskId] })) && JSON.stringify(use.output ?? '').includes(work.nonce)) + const reply = reader && seen.replies.find(reply => reply.order > (reader.output_order ?? reader.order) && reply.text.includes(work.nonce) && reply.session === target.meta.parent_session) + return reply ? { reader, reply } : null + }, 120000, 100) + return s.result(s, 'work_continued', 'harness_transcript', { live_at_delivery: held, work_native_task: work.task, work_start_turn: work.use.turn_id, delivery_turn: check.delivery.turn_id, completion_reader_id: completion.reader.id, work_reply_turn: completion.reply.turn_id, completion_artifact: work.completed }) + } + } + + actions.final_arm = async (_, s) => { + const fixture = fixtureFor(self(s)); if (!fixture.observer) sourceUnconfirmed(s.harness, '終了event observer') + const release = s.artifactFor(s, 'final-release'), nonce = `FINAL_${randomUUID()}` + writeFileSync(fixture.observer.controls, JSON.stringify({ hold: true, release })) + const before = observerRows(fixture).length + state(s).final = { fixture, release, before, nonce } + await fixture.submit(`最終応答と終了境界の試験です。toolを追加せず、符号${nonce}を含む短い最終応答をしてください。`) + const event = await s.until('実Stop hookの到着', () => observerRows(fixture).slice(before).find(row => row.event.hook_event_name === 'Stop' && nativeSession(row.event) === self(s).meta.parent_session), 120000, 50) + if (!s.sameProcess(s.processIdentity(event.pid))) fail('ACCEPTANCE_FINAL_HOOK_NOT_LIVE', '公式Stopの実processがありません') + state(s).final.event = event + // 専用observerは同時起動した所有hookを妨げない。配送を挟んで終了するraceを作る。 + const monitor = { canceled: false, arrival: null } + monitor.promise = (async () => { + await s.until('終了境界での専用本文到着', () => { + if (monitor.canceled) return true + const posted = recordPending(s).find(item => item.posted.label === '終了境界')?.posted + const record = posted && s.recordFor(posted) + if (!record) return null + const owner = s.processIdentity(event.pid) + if (!owner || !s.sameProcess(owner)) fail('ACCEPTANCE_FINAL_BEFORE_ARRIVAL', '専用本文到着前に終了hookが完了しました') + monitor.arrival = { seq: posted.seq, delivery_id: record.delivery_id, owner, record_state: record.state, at: new Date().toISOString() } + writeFileSync(release, '{}'); return true + }, 60000, 10) + })().catch(error => { monitor.error = error; writeFileSync(release, '{}') }) + state(s).final.monitor = monitor + return hookObservation(s, 'final_boundary_armed', { event, official_event_file: fixture.observer.observations }) + } + actions.final_observe = async (_, s) => { + const final = state(s).final, delivered = confirmOne(s) + await final.monitor.promise + if (final.monitor.error) throw final.monitor.error + if (!final.monitor.arrival || final.monitor.arrival.seq !== delivered.original.seq) fail('ACCEPTANCE_FINAL_NATIVE_RACE_MISSING', '同じ専用本文の終了境界到着を確認できません') + await s.until('終了境界observerの完了', () => !s.sameProcess(s.processIdentity(final.event.pid)), 30000, 100) + const replies = self(s).observe().replies.filter(reply => reply.order > delivered.delivery.order && reply.text.includes(delivered.nonce)) + if (replies.length !== 1) fail('ACCEPTANCE_FINAL_DUPLICATE_OR_LOST', '終了競合後の実応答が欠落または重複しました') + return hookObservation(s, 'final_no_loss_duplicate', { final_event: final.event, arrival: final.monitor.arrival, delivery_turn: delivered.delivery.turn_id, reply_turn: replies[0].turn_id, event_artifact: final.fixture.observer.observations }) + } + + for (const prefix of ['claim_race', 'slot_race']) { + actions[`${prefix}_arm`] = async (_, s) => { + const fixture = fixtureFor(self(s)); if (!fixture.competitors || fixture.competitors.count < 2) sourceUnconfirmed(s.harness, '同eventの競合登録') + state(s)[prefix] = { before: rows(fixture.competitors.rows).length, fixture, slot: self(s).spool.read().waiter } + return hookObservation(s, prefix === 'claim_race' ? 'own_claim_competitors' : 'own_simultaneous_hooks', { config_file: fixture.competitors.file, commands: fixture.competitors.commands, competing_hooks: fixture.competitors.count }) + } + actions[`${prefix}_observe`] = async (_, s) => { + const own = state(s)[prefix], check = confirmOne(s) + const proof = await s.until('同eventの競合hook完了', () => { + const events = rows(own.fixture.competitors.rows).slice(own.before) + try { return competitorProof(events, { session: self(s).meta.parent_session, delivery: check.delivery }) } catch (error) { if (error.code === 'ACCEPTANCE_HOOK_COMPETITION_NOT_OBSERVED') return null; throw error } + }, 120000, 100) + const record = s.recordFor({ seq: check.original.seq }) + if (!record.claim?.id || (s.harness === 'codex' && (!record.hook_claim?.id || record.hook_state !== 'output_complete'))) fail('ACCEPTANCE_CLAIM_OWNER_MISSING', '実出力の所有claimがありません') + const waiter = self(s).spool.read().waiter + if (prefix === 'slot_race' && s.harness !== 'codex' && (!waiter || !s.sameProcess(waiter.owner))) fail('ACCEPTANCE_SINGLE_LIVE_SLOT_MISSING', '次受信の存命slotがありません') + return hookObservation(s, prefix === 'claim_race' ? 'single_claim_output' : 'single_live_slot', { proof, claim_id: record.claim.id, hook_claim_id: record.hook_claim?.id, waiter, event_artifact: own.fixture.competitors.rows }) + } + } + + actions.compatibility_arm = async (_, s) => { + const fixture = fixtureFor(self(s)); if (!fixture.competitors?.compatibilityHarness) sourceUnconfirmed(s.harness, '別harness互換hook') + state(s).compatibility = { before: rows(fixture.competitors.rows).length, fixture } + return hookObservation(s, 'own_compatible_hook_loaded', { config_file: fixture.competitors.file, commands: fixture.competitors.commands, compatibility_harness: fixture.competitors.compatibilityHarness }) + } + actions.compatibility_observe = async (_, s) => { + const own = state(s).compatibility, check = confirmOne(s) + const events = rows(own.fixture.competitors.rows).slice(own.before) + const proof = competitorProof(events, { session: self(s).meta.parent_session, delivery: check.delivery, minimum: 1, compatibility: true }) + return hookObservation(s, 'one_adapter_one_binding', { proof, endpoint_id: self(s).spool.id, actual_claim: s.recordFor({ seq: check.original.seq }).claim, event_artifact: own.fixture.competitors.rows }) + } + + actions.session_clear = async (_, s) => { + const old = self(s), fixture = fixtureFor(old), before = observerRows(fixture).length + s.context.registerEndpoint('old', old) + const command = s.harness === 'claude' ? '/clear' : s.harness === 'codex' ? '/new' : fixture.adapter.clear + if (!command) sourceUnconfirmed(s.harness, '公式新会話command') + await fixture.submit(command) + const next = await s.until('公式SessionStartの新CID', () => observerRows(fixture).slice(before).find(row => ['SessionStart', 'sessionStart'].includes(row.event.hook_event_name) && nativeSession(row.event) && nativeSession(row.event) !== old.meta.parent_session), 60000) + state(s).session = { old, fixture, clear: next, newSession: nativeSession(next.event) } + return hookObservation(s, 'new_conversation_identity', { old_session: old.meta.parent_session, related_session: nativeSession(next.event), clear_event: next, event_artifact: fixture.observer.observations }) + } + actions.session_new_join = async (_, s) => { + const change = state(s).session, target = await change.fixture.join(change.old.spool.id) + if (target.meta.parent_session !== change.newSession || target.spool.id === change.old.spool.id) fail('ACCEPTANCE_SESSION_NEW_BINDING', 'clear後のCIDと実joinが一致しません') + s.context.registerEndpoint('new', target) + return hookObservation(s, 'new_binding_verified', { related_session: target.meta.parent_session, endpoint_id: target.spool.id, original_endpoint: change.old.spool.id }) + } + actions.session_resume = async (_, s) => { + const change = state(s).session, current = s.context.endpoint('new') + await change.fixture.stop() + await change.fixture.launch({ resume: change.old.meta.parent_session }) + const resumed = await change.fixture.join() + if (resumed.meta.parent_session !== change.old.meta.parent_session) fail('ACCEPTANCE_RESUME_CID', '公式resumeが旧CIDを返しません') + s.context.registerEndpoint('resumed', resumed) + return s.result(s, 'official_resume_identity', 'harness_transcript', { resumed_session: resumed.meta.parent_session, ended_session: current.meta.parent_session, old_endpoint: change.old.spool.id, resumed_endpoint: resumed.spool.id }) + } + actions.session_isolation_observe = async (_, s) => { + const change = state(s).session, next = s.context.endpoint('new'), oldPosts = recordPending(s).filter(item => item.posted.target === 'old') + if (!oldPosts.length) fail('ACCEPTANCE_OLD_SESSION_SOURCE_MISSING', '旧宛専用投稿がありません') + for (const { posted } of oldPosts) { + if (next.observe().deliveries.some(row => row.body === posted.body || row.delivery_id === s.recordFor(posted)?.delivery_id) || next.observe().replies.some(row => row.text.includes(posted.nonce))) fail('ACCEPTANCE_OLD_BODY_NEW_SESSION', '旧本文が新会話へ誤流入しました') + const record = s.recordFor(posted) + if (record?.event.body !== posted.body) fail('ACCEPTANCE_OLD_BODY_LOST', '旧endpointの原文が保持されていません') + } + return s.result(s, 'old_body_no_new_conversation', 'harness_transcript', { original_session: change.old.meta.parent_session, related_session: next.meta.parent_session, old_seqs: oldPosts.map(item => item.posted.seq), related_transcript: next.file }) + } + + actions.parallel_open = async (_, s) => { + const room = `parent-parallel-${randomUUID()}` + await factory.api(room)('members', { name: 'probe', harness: 'codex' }) + const fixture = await factory.open({ harness: s.harness, room, name: `parallel-${randomUUID().slice(0, 8)}` }); ownedFixtures.add(fixture) + const parallel = s.context.registerEndpoint('parallel', fixture.target) + if (parallel.meta.parent_session === self(s).meta.parent_session || fixture.pkg !== s.pkg) fail('ACCEPTANCE_PARALLEL_FIXTURE', '別会話と共通導入物を確認できません') + state(s).parallel = fixture + return s.result(s, 'two_rooms_two_native_conversations', 'harness_transcript', { first_room: self(s).meta.room, second_room: room, first_endpoint: self(s).spool.id, second_endpoint: parallel.spool.id, related_session: parallel.meta.parent_session, related_transcript: parallel.file }) + } + actions.parallel_observe = async (_, s) => { + const first = self(s), second = s.context.endpoint('parallel') + const messages = { [first.meta.room]: (await first.api('messages')).messages, [second.meta.room]: (await second.api('messages')).messages } + const proof = verifyRelatedIsolation({ primary: first, secondary: second, checks: s.checks, messages }) + const records = [first, second].map(target => target.spool.read().records.filter(record => record.event.seq > 0)) + if (records[0].some(record => records[1].some(other => other.claim?.id && other.claim.id === record.claim?.id))) fail('ACCEPTANCE_PARALLEL_CLAIM_SHARED', '別roomが同claimを共有しました') + return s.result(s, 'claims_room_conversation_separated', 'harness_transcript', { ...proof, related_session: second.meta.parent_session, related_transcript: second.file, first_claims: records[0].map(record => record.claim?.id), second_claims: records[1].map(record => record.claim?.id) }) + } + + actions.receiver_suspend = async (_, s) => { + const target = self(s), fixture = fixtureFor(target), current = target.spool.read() + const owner = s.harness === 'codex' ? current.watcher : current.waiter?.owner + // Codexはsourceとqueueが同watcher。readyが保存された実境界で停止する専用monitorを使う。 + if (s.harness === 'codex') { + const monitor = { canceled: false, owner, before: current.records.length, suspended: null } + monitor.promise = (async () => { + for (;;) { + if (monitor.canceled) return + const ready = target.spool.read().records.find((record, index) => index >= monitor.before && record.state === 'ready' && record.event.seq > 0) + if (ready) { monitor.suspended = { record: ready, operation: await pauseOwnedReceiver(owner, { parent: target.meta.parent_process, sameProcess: s.sameProcess, processDescendsFrom: s.processDescendsFrom, paused }) }; return } + await new Promise(resolve => setTimeout(resolve, 5)) + } + })().catch(error => { monitor.error = error }) + state(s).recovery = { fixture, target, monitor, owner } + if (!owner || !s.sameProcess(owner)) fail('ACCEPTANCE_RECEIVER_NOT_LIVE', '専用watcherが存命ではありません') + return s.result(s, 'own_receiver_suspension_armed', 'os_process', { owner, observation: 'ready保存の実境界を観測するmonitorの準備', monitor_started_at: new Date().toISOString() }) + } + const operation = await pauseOwnedReceiver(owner, { parent: target.meta.parent_process, sameProcess: s.sameProcess, processDescendsFrom: s.processDescendsFrom, paused }) + state(s).recovery = { fixture, target, owner, operation } + return s.result(s, 'own_receiver_suspended', 'os_process', operation) + } + actions.receiver_restart = async (_, s) => { + const recovery = state(s).recovery, post = recordPending(s).at(-1)?.posted + if (!post) fail('ACCEPTANCE_READY_SOURCE_MISSING', s.scenario) + if (recovery.monitor) await s.until('実ready境界でwatcher一時停止', () => { if (recovery.monitor.error) throw recovery.monitor.error; return recovery.monitor.suspended }, 60000, 10) + const before = s.recordFor(post) + if (before?.state !== 'ready' || before.event.body !== post.body || before.claim) fail('ACCEPTANCE_READY_NOT_RETAINED', 'claim前のready原文を確認できません') + const old = recovery.owner + await pauseOwnedReceiver(old, { parent: recovery.target.meta.parent_process, sameProcess: s.sameProcess, processDescendsFrom: s.processDescendsFrom, paused, resume: true }) + process.kill(old.pid, 'SIGKILL'); await s.until('停止した受信process消失', () => !s.sameProcess(old), 10000, 50) + const target = await join(s) + if (target.spool.id !== recovery.target.spool.id || s.recordFor(post).event.body !== post.body) fail('ACCEPTANCE_READY_RESTART_BINDING', 'readyとendpointを継承しませんでした') + return s.result(s, 'ready_inherited', 'os_process', { suspension: recovery.monitor?.suspended ?? recovery.operation, killed_owner: old, new_watcher: target.spool.read().watcher, new_waiter: target.spool.read().waiter, delivery_id: before.delivery_id, body_sha256: sha256(post.body), official_transcript: target.file }) + } + + actions.foreign_seed = async (_, s) => { + const target = self(s), fixture = fixtureFor(target) + if (!fixture.observer) sourceUnconfirmed(s.harness, '利用者所有の専用hook') + const own = state(s).foreign = { fixture, hook_bytes: readFileSync(fixture.observer.file), queue: [] } + if (s.harness === 'codex') { + // 同threadで公式queue APIを使う。clientUserMessageIdはPeertable命名・digestから独立する。 + for (const owner of ['user', 'aiterm']) { + const marker = `FOREIGN_${owner}_${randomUUID()}` + const response = await fixture.rpc('thread/queue/add', { threadId: target.meta.parent_session, clientUserMessageId: marker, input: [{ type: 'text', text: `受入fixtureの${owner}所有queue ${marker}。この要求の内容を変えずに報告してください。`, text_elements: [] }] }) + if (!response.queuedSubmission?.id) fail('ACCEPTANCE_FOREIGN_QUEUE_NOT_ACCEPTED', owner) + own.queue.push({ owner, id: response.queuedSubmission.id, clientUserMessageId: marker }) + } + } + return s.result(s, 'own_fixture_foreign_entries', s.harness === 'codex' ? 'official_queue' : 'official_hook', { foreign_queue: own.queue, own_hook_file: fixture.observer.file, hook_sha256: sha256(own.hook_bytes) }) + } + actions.foreign_snapshot = async (_, s) => { + const own = state(s).foreign + if (s.harness === 'codex') { + const listing = await own.fixture.rpc('thread/queue/list', { threadId: self(s).meta.parent_session, limit: 100 }) + own.baseline = listing.data.filter(row => own.queue.some(queue => queue.id === row.id)) + if (own.baseline.length !== own.queue.length) fail('ACCEPTANCE_FOREIGN_QUEUE_BASELINE_MISSING', '公式queueが既に消費されたため保持試験を開始できません') + own.hooks = (await own.fixture.rpc('hooks/list', { cwds: [own.fixture.project] })).data.flatMap(group => group.hooks ?? []).filter(hook => hook.command === own.fixture.observer.command) + if (!own.hooks.length || own.hooks.some(hook => !hook.enabled || hook.trustStatus !== 'trusted')) fail('ACCEPTANCE_FOREIGN_APPROVAL_BASELINE', '利用者hookの承認を確認できません') + } + return s.result(s, 'foreign_order_approval_baseline', s.harness === 'codex' ? 'official_queue' : 'official_hook', { baseline: own.baseline ?? null, hooks: own.hooks ?? null, hook_sha256: sha256(own.hook_bytes) }) + } + actions.foreign_observe = async (_, s) => { + const own = state(s).foreign; confirmOne(s) + if (!readFileSync(own.fixture.observer.file).equals(own.hook_bytes)) fail('ACCEPTANCE_FOREIGN_HOOK_CHANGED', '利用者hook bytesを変更しました') + own.fixture.configuration.assertHooksUnchanged() + if (s.harness === 'codex') { + const after = (await own.fixture.rpc('thread/queue/list', { threadId: self(s).meta.parent_session, limit: 100 })).data.filter(row => own.queue.some(queue => queue.id === row.id)) + const hooks = (await own.fixture.rpc('hooks/list', { cwds: [own.fixture.project] })).data.flatMap(group => group.hooks ?? []).filter(hook => hook.command === own.fixture.observer.command) + if (!isDeepStrictEqual(after, own.baseline) || !isDeepStrictEqual(hooks, own.hooks)) fail('ACCEPTANCE_FOREIGN_QUEUE_OR_APPROVAL_CHANGED', '同threadの他所有queue・順序・承認が変わりました') + } + if (!observerRows(own.fixture).some(row => nativeSession(row.event) === self(s).meta.parent_session)) fail('ACCEPTANCE_FOREIGN_HOOK_NOT_EXECUTED', '利用者hookが実親で発火していません') + return s.result(s, 'foreign_entries_unchanged', 'official_hook', { event_artifact: own.fixture.observer.observations, queue_baseline: own.baseline ?? null, hook_sha256: sha256(own.hook_bytes) }) + } + + // native taskの操作名・実task readerは各harnessの公式実測controllerだけを使用する。 + // controllerは任意の合否callbackを持たず、request/responseと公式task出力を返す。 + for (const [action, outcome] of [['background_cancel', 'cancel'], ['background_timeout', 'timeout'], ['background_exit', 'exit']]) { + actions[action] = async (_, s) => { + const target = self(s), fixture = fixtureFor(target), task = target.spool.read().waiter?.native_task + if (!task || !fixture.adapter.taskController) sourceUnconfirmed(s.harness, `受信task ${outcome}`, { task_id: task?.id ?? null, native_task: task ?? null }) + if (!s.sameProcess(task.process_identity) || !s.sameProcess(target.spool.read().waiter.owner) || !s.processDescendsFrom(target.spool.read().waiter.owner, task.process_identity)) fail('ACCEPTANCE_NATIVE_TASK_OWNER', '製品receiptの実task所有を確認できません') + const controller = fixture.adapter.taskController + const before = await controller.read(task.id, { session: target.meta.parent_session }) + const response = await controller.request(outcome, { id: task.id, session: target.meta.parent_session }) + const after = await s.until('公式native task終了出力', async () => { const value = await controller.read(task.id, { session: target.meta.parent_session }); return value.finished === true ? value : null }, outcome === 'timeout' ? controller.timeoutWindowMs : 30000) + if (after.id !== task.id || after.session !== target.meta.parent_session || after.outcome !== outcome || after.delivered === true) fail('ACCEPTANCE_BACKGROUND_END_ROUNDED', 'cancel/timeout/exitを本文配送へ丸めました') + state(s).background ??= []; state(s).background.push({ task, before, response, after }) + if (outcome !== 'exit') await join(s) + return s.result(s, `native_task_${outcome}_observed`, 'native_task', { native_task: task, request_response: response, before, after, task_artifact: after.artifact }) + } + } + actions.background_observe = async (_, s) => { + const observations = state(s).background + if (!observations || observations.length !== 3 || observations.some(item => item.after.delivered === true || JSON.stringify(item.after.output).includes('[Peertable'))) fail('ACCEPTANCE_TASK_END_BODY_CONFUSED', '3種類の終了観測が揃わないか本文へ混入しました') + return s.result(s, 'task_end_not_delivered', 'native_task', { native_ends: observations }) + } + actions.receiver_rearm = async (_, s) => { + const before = self(s).spool.read(), target = await join(s), after = target.spool.read() + if (target.spool.id !== self(s).spool.id || after.cursor < before.cursor || after.state !== 'verified' || after.runtime !== 'armed') fail('ACCEPTANCE_REARM_IDENTITY_OR_CURSOR', '同endpoint/cursorで再武装していません') + if (['cursor', 'grok'].includes(s.harness) && (!after.waiter?.native_task || !s.sameProcess(after.waiter.owner))) fail('ACCEPTANCE_REARM_NATIVE_TASK_MISSING', '完成済みnative taskの再登録がありません') + return s.result(s, s.scenario === 'lease' ? 'same_endpoint_cursor_rearmed' : 'native_receiving_rearmed', 'harness_transcript', { endpoint_id: target.spool.id, before_cursor: before.cursor, after_cursor: after.cursor, native_waiter: after.waiter }) + } + + actions.lease_expiry_arm = async (_, s) => { + const target = self(s), fixture = fixtureFor(target), before = target.spool.read() + if (s.harness === 'codex') { + const artifact = s.artifactFor(s, 'queue-connections'), monitor = monitorQueueConnections({ watcher: before.watcher, executable: before.caller.owner.executable, processIdentity: s.processIdentity, sameProcess: s.sameProcess, artifact }) + state(s).lease = { before, fixture, monitor, artifact, armed_at: new Date().toISOString() } + return s.result(s, 'own_finite_lease', 'os_process', { mechanism: '公式queue接続更新', watcher: before.watcher, process_artifact: artifact, product_connection_source: join(s.pkg, 'skill/scripts/parent-receivers/codex.mjs') }) + } + if (!before.waiter?.owner || !s.sameProcess(before.waiter.owner)) fail('ACCEPTANCE_LEASE_OWNER_MISSING', '製品受信processのlease開始を確認できません') + const controller = fixture.adapter.taskController + state(s).lease = { before, fixture, armed_at: new Date().toISOString(), controller } + // 製品の有限leaseを短縮・時計改変しない。正式runは実際の期限まで待つ。 + return s.result(s, 'own_finite_lease', s.harness === 'claude' ? 'official_hook' : 'native_task', { endpoint_id: target.spool.id, owner: before.waiter.owner, native_task: before.waiter.native_task ?? null, lease_source: join(s.pkg, `skill/scripts/parent-receivers/${s.harness === 'claude' ? 'claude' : 'background'}.mjs`) }) + } + actions.lease_expiry_observe = async (_, s) => { + const lease = state(s).lease, target = self(s) + if (s.harness === 'codex') { + const first = confirmOne(s) + const closed = await s.until('queue受付後の公式接続終了', () => { + if (lease.monitor.error) throw lease.monitor.error + const at = Date.parse(first.receipt.accepted_at) + return lease.monitor.connections.find(connection => Date.parse(connection.first_seen_at) <= at && connection.closed_at && Date.parse(connection.closed_at) >= at) + }, 30000, 100) + if (target.spool.read().endpoint_id !== lease.before.endpoint_id || target.spool.read().cursor < lease.before.cursor) fail('ACCEPTANCE_QUEUE_LEASE_IDENTITY', '接続終了がendpoint/cursorを失いました') + return s.result(s, 'finite_lease_control_not_body', 'official_queue', { mechanism: '公式queue接続更新', connection: closed, queued_submission_id: first.receipt.queued_submission_id, accepted_at: first.receipt.accepted_at, queue_snapshot: await lease.fixture.rpc('thread/queue/list', { threadId: target.meta.parent_session, limit: 100 }), process_artifact: lease.artifact, control_notification_required: false }) + } + // 期限前配送で最初のslotが終了し、親が作る次slotの実expiryを観測する。 + const waiter = await s.until('期限前配送後の次受信slot', () => { const current = target.spool.read().waiter; return current && current.owner.pid !== lease.before.waiter.owner.pid && s.sameProcess(current.owner) ? current : null }, 60000) + const file = s.artifactFor(s, 'lease-control-output'), transcriptBefore = target.observe().rows + let output + if (s.harness === 'claude') { + output = await s.until('通常asyncRewakeの有限lease終了', () => { const raw = readFileSync(target.file, 'utf8'), added = raw.split('\n').slice(transcriptBefore).join('\n'); return added.includes('PARENT_RECEIVER_EXPIRED') && added.includes(target.spool.id) && !s.sameProcess(waiter.owner) ? added : null }, 86460000, 5000) + writeFileSync(file, output) + } else { + if (!lease.controller || !waiter.native_task?.id) sourceUnconfirmed(s.harness, '有限lease完了task reader') + output = await s.until('通常native taskの有限lease終了', async () => { const value = await lease.controller.read(waiter.native_task.id, { session: target.meta.parent_session }); return value.finished === true ? value : null }, 86520000, 5000) + if (output.outcome !== 'timeout' || !JSON.stringify(output.output).includes('PARENT_RECEIVER_EXPIRED')) fail('ACCEPTANCE_LEASE_CONTROL_MISSING', '公式期限通知を取得できません') + writeFileSync(file, JSON.stringify(output)) + } + if (target.observe().deliveries.some(delivery => delivery.body?.includes('peertable.parent-control.v1') || delivery.body?.includes('PARENT_RECEIVER_EXPIRED'))) fail('ACCEPTANCE_LEASE_CONTROL_AS_BODY', '制御通知がroom本文へ混入しました') + return s.result(s, 'finite_lease_control_not_body', s.harness === 'claude' ? 'harness_transcript' : 'native_task', { expired_owner: waiter.owner, output_artifact: file, endpoint_id: target.spool.id, cursor: target.spool.read().cursor }) + } + + // この3障害は同じglobal所有hookの実効状態を変える必要がある。共有状態の無断変更は禁止。 + for (const [action, fault] of [['hook_disable', 'disabled'], ['hook_untrust', 'untrusted'], ['hook_remove_file', 'file_missing']]) { + actions[action] = async (_, s) => { + const fixture = fixtureFor(self(s)), config = fixture.configuration + if (s.harness === 'codex' && fault !== 'file_missing') { + const proof = await fixture.setCodexOwnedHookFault(fault) + state(s).hookFault = { fixture, fault, proof } + return s.result(s, fault === 'disabled' ? 'own_hook_disabled' : 'own_hook_untrusted', 'official_hook', proof) + } + const observed = s.harness === 'codex' ? await fixture.rpc('hooks/list', { cwds: [fixture.project] }) : config.beforeHooks + const artifact = s.artifactFor(s, `global-${fault}-boundary`) + writeFileSync(artifact, JSON.stringify({ harness: s.harness, fault, sourcePath: config.hooks, observed, reason: '共有global所有hookと通常配布物を保持したまま、専用sessionだけに適用する公式経路の確認が必要' }, null, 2)) + fail('ACCEPTANCE_SHARED_HOOK_FAULT_SCOPE_UNCONFIRMED', `${s.harness}/${fault}: 他親へ影響しない同global所有hook障害の公式経路が未確認です`, { sourcePath: config.hooks, artifact }) + } + } + actions.join_expect_failure = async (_, s) => { + const fault = state(s).hookFault + if (!fault) sourceUnconfirmed(s.harness, '専用sessionに限定したhook障害join') + const target = self(s), before = target.observe().rows + await fault.fixture.submit(`Peertable parent_joinをproject=${fault.fixture.project} name=${fault.fixture.name}で1回呼び、返されたerror codeをそのまま報告してください。再試行や設定変更はしないでください。`) + const expected = 'PARENT_CODEX_HOOK_UNTRUSTED' + const observed = await s.until('障害joinの公式tool返答', () => { + const seen = target.observe(), uses = seen.toolUses.filter(use => use.order >= before && /parent_join$/u.test(use.name ?? '')) + for (const use of uses) { + const output = seen.toolUses.find(row => row.name === 'output' && row.id === use.id && row.order > use.order && productToolError(row.output, expected)) + if (output) return { use, output } + } + return null + }, 120000, 100) + const failed = target.spool.read() + const error = productToolError(observed.output.output, expected) + if (!error) fail('ACCEPTANCE_HOOK_JOIN_NOT_TYPED_FAILED', '公式MCP parent-errorのschema/state/error_codeが一致しません') + return s.result(s, fault.fault === 'disabled' ? 'disabled_hook_typed_failure' : 'untrusted_hook_typed_failure', 'harness_transcript', { error_code: error.error_code, existing_endpoint_state: failed.state, failure_stage: 'joinEndpointより前の公式receiver検証', tool_use_id: observed.use.id, official_tool_output: observed.output.output, parent_session: target.meta.parent_session, turn_id: observed.output.turn_id, sourcePath: fault.proof.global_sourcePath, local_config: fault.proof.local_config }) + } + actions.hook_restore = async (_, s) => { fixtureFor(self(s)).configuration.assertHooksUnchanged(); return s.result(s, 'normal_launch_intact', 'official_hook', { configuration_unchanged: true }) } + + // 期限は製品の実bind/probe境界で待つ。保存stateへ時刻を代入するfixtureは受入へ使わない。 + for (const action of ['binding_timeout_arm', 'binding_timeout_observe', 'probe_timeout_arm', 'probe_timeout_observe', 'deadline_restore']) { + actions[action] = async (_, s) => { sourceUnconfirmed(s.harness, '公式MCP相関を保持した専用binding/probe遮断', { source: join(s.pkg, 'skill/scripts/parent-caller.mjs'), probe_source: join(s.pkg, 'skill/scripts/parent-watch.mjs'), parent_session: self(s).meta.parent_session }) } + } + + const lifecycleOperation = async (s, mode) => { + if (!lifecycle?.currentTarball || !lifecycle?.nextTarball || !lifecycle?.prefix || !lifecycle?.exclusiveEndpoints) sourceUnconfirmed(s.harness, '共通導入物の同版/新版更新の専用所有範囲') + const { endpointsFor } = await import(pathToFileURL(join(s.pkg, 'skill/scripts/parent-caller.mjs')).href) + const others = endpointsFor().filter(endpoint => endpoint.read().runtime !== 'stopped' && !lifecycle.exclusiveEndpoints.includes(endpoint.id)) + if (others.length) fail('ACCEPTANCE_SHARED_INSTALL_BUSY', '更新が試験外の親へ影響します', { endpoints: others.map(endpoint => endpoint.id) }) + const target = self(s), fixture = fixtureFor(target) + if (!fixture.adapter.reconnectMcp) sourceUnconfirmed(s.harness, '通常CLIの公式MCP再接続command', { parent_session: target.meta.parent_session }) + const before = target.spool.read(), tarball = mode === 'same' ? lifecycle.currentTarball : lifecycle.nextTarball + const argv = ['install', '--prefix', lifecycle.prefix, '--ignore-scripts', '--no-audit', '--no-fund', tarball] + const output = execFileSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', argv, { encoding: 'utf8', shell: process.platform === 'win32' }) + const installed = JSON.parse(readFileSync(join(s.pkg, 'package.json'), 'utf8')) + if (mode === 'same' && installed.version !== s.meta.package_version || mode === 'new' && installed.version === s.meta.package_version) fail('ACCEPTANCE_LIFECYCLE_VERSION', '要求した同版/新版の導入が不一致です') + const after = target.spool.read() + if (!s.sameProcess(target.meta.parent_process) || after.cursor !== before.cursor || !isDeepStrictEqual(after.records, before.records)) fail('ACCEPTANCE_LIFECYCLE_HISTORY_LOST', '更新が親processまたは配送履歴を変更しました') + await fixtureFor(target).adapter.reconnectMcp(fixtureFor(target)) + await join(s) + return s.result(s, mode === 'same' ? 'same_version_single_registration' : 'new_version_history_binding_preserved', 'installed_package', { before_version: s.meta.package_version, after_version: installed.version, tarball_sha256: sha256(readFileSync(tarball)), package_output: output, endpoint_id: target.spool.id, cursor: after.cursor }) + } + actions.same_version_update = (_, s) => lifecycleOperation(s, 'same') + actions.new_version_update = (_, s) => lifecycleOperation(s, 'new') + actions.teardown = async (_, s) => { + const target = self(s), fixture = fixtureFor(target), original = (await target.api('messages')).messages + if (!lifecycle?.exclusiveEndpoints) sourceUnconfirmed(s.harness, '公式teardownのglobal所有範囲') + // 製品teardownが共通接続を解除する可能性を事前に排除できない時は動かさない。 + const { endpointsFor } = await import(pathToFileURL(join(s.pkg, 'skill/scripts/parent-caller.mjs')).href) + if (!endpointsFor().some(endpoint => endpoint.project !== fixture.project && endpoint.read().harness === s.harness && endpoint.read().runtime !== 'stopped')) fail('ACCEPTANCE_TEARDOWN_SHARED_CONNECTION_SCOPE', '通常global接続を保持する別fixtureが必要です') + const bin = join(lifecycle.prefix, 'node_modules/.bin', process.platform === 'win32' ? 'peertable.cmd' : 'peertable') + const output = execFileSync(bin, ['teardown', fixture.project], { cwd: fixture.project, env: { ...process.env, PEERTABLE_TOKEN_SOURCE_FILE: lifecycle.tokenFile }, encoding: 'utf8', shell: process.platform === 'win32' }) + const messages = (await target.api('messages')).messages + if (!s.sameProcess(target.meta.parent_process) || original.some(before => !messages.some(after => isDeepStrictEqual(before, after)))) fail('ACCEPTANCE_TEARDOWN_PARENT_OR_HISTORY', 'teardownで親またはroom履歴を失いました') + fixture.configuration.assertHooksUnchanged() + state(s).teardown = { fixture, old: target, original, output } + return s.result(s, 'receiver_stopped_parent_alive_history_kept', 'harness_transcript', { parent_process: target.meta.parent_process, old_endpoint: target.spool.id, archived_files: readdirSync(join(fixture.project, 'docs/archive')), command_output: output }) + } + actions.lifecycle_rejoin = async (_, s) => { + const previous = state(s).teardown + mkdirSync(join(previous.fixture.project, '.team'), { recursive: true }) + writeFileSync(join(previous.fixture.project, '.team/setup-state.json'), JSON.stringify({ room: previous.old.meta.room, server_url: lifecycle.serverUrl, mode: 'adhoc' })) + const target = await previous.fixture.join() + if (target.spool.id === previous.old.spool.id || target.meta.parent_session !== previous.old.meta.parent_session) fail('ACCEPTANCE_LIFECYCLE_REJOIN_BINDING', '新bindingと同会話を確認できません') + s.context.registerEndpoint('self', target) + return s.result(s, 'new_binding_after_teardown', 'harness_transcript', { endpoint_id: target.spool.id, old_endpoint: previous.old.spool.id }) + } + + return { actions, async finalize(s) { + const errors = [] + try { + const current = saved.get(s.runId) + if (current?.work) { writeFileSync(current.work.release, '{}'); if (current.work.owner) await waitOwnedFixtureExit({ readEndpoints: () => [], knownOwners: [current.work.owner], indexExists: () => false, sameProcess: s.sameProcess, timeout: 30000 }) } + if (current?.final) { writeFileSync(current.final.release, '{}'); if (current.final.monitor) { current.final.monitor.canceled = true; await current.final.monitor.promise } } + if (current?.recovery?.monitor) { current.recovery.monitor.canceled = true; await current.recovery.monitor.promise } + for (const owner of paused.values()) if (s.sameProcess(owner)) await pauseOwnedReceiver(owner, { parent: self(s).meta.parent_process, sameProcess: s.sameProcess, processDescendsFrom: s.processDescendsFrom, paused, resume: true }) + // queue撤去はこのfixtureが公式APIで作成したIDだけ。利用者/Aiterm実設定を削除しない。 + for (const entry of current?.foreign?.queue ?? []) await current.foreign.fixture.rpc('thread/queue/delete', { threadId: self(s).meta.parent_session, queuedSubmissionId: entry.id }) + if (current?.lease?.monitor) { + const monitor = current.lease.monitor + for (const connection of monitor.connections) if (!connection.closed_at && !s.sameProcess(connection.owner)) connection.closed_at = new Date().toISOString() + await monitor.close() + if (s.checks.length >= 2) { + const proof = queueConnectionProof(monitor.connections, s.checks) + writeFileSync(current.lease.artifact, JSON.stringify({ connections: monitor.connections, native_acceptance_proof: proof }, null, 2)) + } + } + } catch (error) { errors.push(error) } + try { await factory.close() } catch (error) { errors.push(error) } + if (errors.length) throw Object.assign(new AggregateError(errors, '追加scenarioの所有資産を後片付けできません'), { code: 'ACCEPTANCE_NATIVE_CLEANUP_FAILED' }) + } } +} + +// rootの既存runnerへ接続する入口。scenarioごとに新CIDと専用artifactを作り、証拠を使い回さない。 +export async function createNativeScenarioContext(name, options) { + const surfaceAdapters = { ...(['cursor', 'grok'].includes(options.sourceMeta.harness) ? await createBackgroundSurfaceAdapters(options) : {}), ...options.surfaceAdapters } + const factory = await createNativeFixtureFactory({ ...options, surfaceAdapters }) + let primary + try { + primary = await factory.open({ harness: options.sourceMeta.harness, name: `scenario-${name}-${randomUUID().slice(0, 8)}`, prepare: async fixture => { + if (['final_race', 'session_change', 'foreign_ownership'].includes(name)) await fixture.addObserver({ events: name === 'session_change' ? ['SessionStart'] : ['Stop', 'PostToolUse'] }) + if (['claim_race', 'slot_race'].includes(name)) await fixture.addProductCompetitors({ count: 2 }) + if (name === 'compatibility_hooks') await fixture.addProductCompetitors({ count: 1, compatibilityHarness: options.sourceMeta.harness === 'claude' ? 'codex' : 'claude' }) + } }) + const native = createNativeActions({ factory, primary, lifecycle: options.lifecycle }) + const context = await createScenarioContext({ ...options, ...primary.target, projectDir: primary.project, privateDir: primary.directory, nativeActions: native.actions }) + context.registerEndpoint('self', primary.target) + const ordinaryFinalize = context.finalize + // native finalizeには同じ実境界の所有helpersを渡す。判定をcallbackから受け取らない。 + const platform = await import(pathToFileURL(join(options.pkg, 'skill/scripts/parent-platform.mjs')).href) + context.finalize = async scope => { const errors = []; try { await ordinaryFinalize(scope) } catch (error) { errors.push(error) }; try { await native.finalize({ ...scope, context, ...platform }) } catch (error) { errors.push(error) }; if (errors.length) throw new AggregateError(errors, 'scenario所有process/設定の後片付けに失敗しました') } + context.caseMeta = primary.target.meta + return context + } catch (error) { try { await factory.close() } catch (cleanup) { throw new AggregateError([error, cleanup], 'fixture準備と後片付けに失敗しました') }; throw error } +} diff --git a/experiments/parent-product-acceptance/scenarios-native.test.mjs b/experiments/parent-product-acceptance/scenarios-native.test.mjs new file mode 100644 index 00000000..23a299ee --- /dev/null +++ b/experiments/parent-product-acceptance/scenarios-native.test.mjs @@ -0,0 +1,77 @@ +// 追加adapterの誤相関をfocusedで確認する。fixtureの合格をproduct_liveへ流用しない。 +import test from 'node:test' +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { createInterface } from 'node:readline' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { competitorProof, queueConnectionProof, monitorQueueConnections, createNativeActions, productToolError } from './scenarios-native.mjs' +import { processIdentity, sameProcess } from '../../skill/scripts/parent-platform.mjs' +import { scenarioPlan } from './scenarios.mjs' + +const event = { session_id: 'own', turn_id: 'turn', hook_event_name: 'PostToolUse', tool_use_id: 'tool' } +const pair = (pid, value = event, stdout = '{}', code = 0) => [{ phase: 'started', pid, event: value }, { phase: 'completed', pid, event: value, stdout, code }] + +test('競合証拠は同eventの異なる実processに限定し、event合算と二重完了を拒否する', () => { + const options = { session: 'own', delivery: { turn_id: 'turn' } } + assert.equal(competitorProof([...pair(1), ...pair(2)], options).starts.length, 2) + assert.throws(() => competitorProof([...pair(1), ...pair(2, { ...event, tool_use_id: '別tool' })], options), { code: 'ACCEPTANCE_HOOK_COMPETITION_NOT_OBSERVED' }) + assert.throws(() => competitorProof([...pair(1), ...pair(1)], options), { code: 'ACCEPTANCE_HOOK_COMPETITION_NOT_OBSERVED' }) + assert.throws(() => competitorProof([...pair(1, event, '本文'), ...pair(2, event, '本文')], options), { code: 'ACCEPTANCE_CLAIM_OUTPUT_DUPLICATED' }) + assert.throws(() => competitorProof(pair(1, event, '本文'), { ...options, minimum: 1, compatibility: true }), { code: 'ACCEPTANCE_COMPATIBILITY_DUPLICATE' }) + assert.throws(() => competitorProof([...pair(1, { ...event, session_id: '他親' }), ...pair(2, { ...event, session_id: '他親' })], options), { code: 'ACCEPTANCE_HOOK_COMPETITION_NOT_OBSERVED' }) +}) + +test('Codexの停止準備は停止済みと区別し、他harnessの実停止契約を保持する', () => { + assert.equal(scenarioPlan('process_recovery', { harness: 'codex' })[0].expectation, 'own_receiver_suspension_armed') + assert.equal(scenarioPlan('process_recovery', { harness: 'claude' })[0].expectation, 'own_receiver_suspended') +}) + +test('公式queue受付は接続存命時間と一意に照合し、新しい接続を要求する', () => { + const make = (pid, first, closed) => ({ owner: { pid, started: String(pid) }, first_seen_at: new Date(first).toISOString(), closed_at: new Date(closed).toISOString() }) + const check = (seq, at) => ({ original: { seq }, receipt: { queued_submission_id: `queue-${seq}`, accepted_at: new Date(at).toISOString() } }) + assert.equal(queueConnectionProof([make(1, 100, 200), make(2, 250, 350)], [check(1, 150), check(2, 300)]).length, 2) + assert.throws(() => queueConnectionProof([make(1, 100, 350)], [check(1, 150), check(2, 300)]), { code: 'ACCEPTANCE_QUEUE_CONNECTION_NOT_UPDATED' }) + assert.throws(() => queueConnectionProof([make(1, 100, 250), make(2, 120, 240)], [check(1, 150), check(2, 200)]), { code: 'ACCEPTANCE_QUEUE_CONNECTION_UNBOUND' }) + assert.throws(() => queueConnectionProof([make(1, 100, 250)], [{ original: { seq: 1 }, receipt: {} }]), { code: 'ACCEPTANCE_QUEUE_ACCEPTANCE_MISSING' }) +}) + +test('接続monitorは専用watcherの実child起動と消失を観測する', async t => { + const dir = mkdtempSync(join(tmpdir(), 'peertable-queue-process-')); t.after(() => rmSync(dir, { recursive: true, force: true })) + // 公式app-serverの動作は代替しない。実childのtree/identityを読むobserver部分だけの試験。 + const script = `const {spawn}=require('node:child_process');const child=spawn(process.execPath,['-e','setTimeout(()=>process.exit(0),700)','app-server','--listen','stdio://'],{stdio:'ignore'});child.on('spawn',()=>console.log(JSON.stringify({pid:child.pid})));child.on('exit',()=>console.log(JSON.stringify({closed:true})));process.stdin.resume();process.stdin.on('end',()=>process.exit(0));` + const watcher = spawn(process.execPath, ['-e', script], { stdio: ['pipe', 'pipe', 'inherit'] }) + t.after(() => { if (watcher.exitCode === null) watcher.kill('SIGKILL') }) + const lineReader = createInterface({ input: watcher.stdout }); t.after(() => lineReader.close()) + const lines = []; lineReader.on('line', line => lines.push(JSON.parse(line))) + const identity = processIdentity(watcher.pid) + const monitor = monitorQueueConnections({ watcher: identity, executable: process.execPath, processIdentity, sameProcess, artifact: join(dir, 'connections.json'), interval: 30 }) + t.after(async () => { monitor.canceled = true; await monitor.promise }) + const end = Date.now() + 4000 + while (!monitor.connections[0]?.closed_at) { if (monitor.error) throw monitor.error; assert.ok(Date.now() < end, '実childの接続終了を観測できません'); await new Promise(resolve => setTimeout(resolve, 30)) } + await monitor.close() + assert.equal(monitor.connections.length, 1); assert.equal(monitor.connections[0].owner.pid, lines[0].pid) + assert.equal(monitor.connections[0].watcher.pid, watcher.pid) + watcher.stdin.end(); await new Promise(resolve => watcher.once('exit', resolve)) +}) + +test('final armがobserver待ちで失敗してもreleaseとfactory cleanupを実行する', async t => { + const dir = mkdtempSync(join(tmpdir(), 'peertable-final-cleanup-')); t.after(() => rmSync(dir, { recursive: true, force: true })) + let closed = false + const fixture = { observer: { controls: join(dir, 'controls'), observations: join(dir, 'events') }, submit: async () => {} } + const primary = { target: { fixture, meta: { parent_session: 'own' } } } + const native = createNativeActions({ factory: { close: async () => { closed = true } }, primary: fixture }) + const scope = { runId: 'run', context: { endpoint: () => primary.target }, artifactFor: () => join(dir, 'release'), until: async () => { throw Object.assign(new Error('実event待ち失敗'), { code: 'TEST_EVENT_TIMEOUT' }) }, sameProcess: () => false } + await assert.rejects(native.actions.final_arm({}, scope), { code: 'TEST_EVENT_TIMEOUT' }) + await native.finalize(scope); assert.equal(closed, true) +}) + + +test('join失敗は公式parent-errorだけから判定し、既存endpointや自由文から推測しない', () => { + const error = { schema: 'peertable.parent-error.v1', state: 'failed', error_code: 'PARENT_CODEX_HOOK_UNTRUSTED' } + assert.deepEqual(productToolError(JSON.stringify({ content: [{ type: 'text', text: JSON.stringify(error) }], structuredContent: error, isError: true }), error.error_code), error) + assert.equal(productToolError(`説明中の ${error.error_code}`, error.error_code), null) + assert.equal(productToolError({ ...error, state: 'verified' }, error.error_code), null) + assert.equal(productToolError({ ...error, error_code: '別code' }, error.error_code), null) +}) diff --git a/experiments/parent-product-acceptance/scenarios-surfaces.mjs b/experiments/parent-product-acceptance/scenarios-surfaces.mjs new file mode 100644 index 00000000..2f8b7469 --- /dev/null +++ b/experiments/parent-product-acceptance/scenarios-surfaces.mjs @@ -0,0 +1,87 @@ +// Cursor/Grok公式CLIの起動と試験project設定。会話/taskの観測はbackground-harness.mjsへ接続する。 +import { existsSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { homedir } from 'node:os' +import { execFileSync } from 'node:child_process' +import { pathToFileURL } from 'node:url' + +const fail = (code, message) => { throw Object.assign(new Error(message), { code }) } +export async function createBackgroundSurfaceAdapters({ pkg, tokenFile, backgroundObserverFactory }) { + if (!backgroundObserverFactory) { + try { backgroundObserverFactory = (await import('./background-harness.mjs')).createBackgroundHarnessObserver } + catch (error) { if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error; fail('ACCEPTANCE_BACKGROUND_OBSERVER_MISSING', '正式background-harness observerが必要です') } + } + const { hookEntries, parentRegistration } = await import(pathToFileURL(join(pkg, 'skill/scripts/parent-connect.mjs')).href) + const platform = await import(pathToFileURL(join(pkg, 'skill/scripts/parent-platform.mjs')).href) + const resolve = harness => { + const name = harness === 'cursor' ? 'cursor-agent' : 'grok' + const executable = platform.resolveExecutable(name) + const version = execFileSync(executable, ['--version'], { encoding: 'utf8', shell: process.platform === 'win32' }).trim().split(/\r?\n/u)[0] + return { executable, version } + } + const adapters = {} + for (const harness of ['cursor', 'grok']) adapters[harness] = { + bind(fixture) { + const hookFile = join(fixture.directory, 'product-hook-events.jsonl'), observers = new Map() + const transcriptPath = session => harness === 'cursor' ? join(homedir(), '.cursor/projects', fixture.project.replace(/[^a-zA-Z0-9]+/gu, '-').replace(/^-+/u, ''), 'agent-transcripts', session, `${session}.jsonl`) : join(homedir(), '.grok/sessions', encodeURIComponent(fixture.project), session, 'updates.jsonl') + const observeSession = session => { + const file = transcriptPath(session) + if (!observers.has(session)) observers.set(session, backgroundObserverFactory({ harness, transcriptPath: file, hookPaths: [hookFile], expectedConversationId: session })) + return observers.get(session).observe() + } + return { + resolveCli: () => resolve(harness), + projectHookFile: project => harness === 'cursor' ? join(project, '.cursor/hooks.json') : join(project, '.grok/hooks/acceptance.json'), + argv: () => harness === 'cursor' ? ['--force', '--approve-mcps', '--trust'] : ['--always-approve'], + resumeArgs: session => ['--resume', session], + // 終了command未確認のharnessはown PTY closeを使う。未知のslash commandを送らない。 + exit: null, + startup(screen) { + if (/authentication required|Sign in to Cursor|not logged in/iu.test(screen)) return { blocked: 'official_auth_required' } + if (harness === 'cursor' && /Add a follow-up|Cursor Agent/u.test(screen) && /Run Everything/u.test(screen)) return { ready: true } + if (harness === 'grok' && /trust.*(?:folder|directory)|(?:folder|directory).*trust/iu.test(screen) && /\by\b|Yes/u.test(screen)) return { keys: ['y', 'Enter'], reason: 'own_grok_project_trust' } + if (harness === 'grok' && /Grok 4|grok.*build/iu.test(screen) && /❯|›|>\s*$/mu.test(screen)) return { ready: true } + return null + }, + nativeTaskOutputPath: id => harness === 'cursor' ? join(homedir(), '.cursor/projects', fixture.project.replace(/[^a-zA-Z0-9]+/gu, '-').replace(/^-+/u, ''), 'terminals', `${id}.txt`) : null, + transcript: session => { const file = transcriptPath(session); return existsSync(file) ? file : null }, + read: (file, { session }) => observeSession(session), + taskController: { + timeoutWindowMs: 86520000, + async read(id, { session }) { + const records = observeSession(session).tasks.filter(task => String(task.task_id ?? task.id) === String(id)) + const read = records.find(task => task.reader_tool_use_id && task.result?.schema === 'peertable.parent-background-result.v1') + if (!read) return { id: String(id), session, finished: false, outcome: null } + const completed = records.find(task => task.raw_output?.completed === true) + if (harness === 'grok' && !completed) fail('ACCEPTANCE_GROK_NATIVE_TASK_END_MISSING', '実taskの完了snapshotがありません') + const result = read.result + return { id: String(id), session, finished: true, outcome: result.outcome === 'timeout' && result.error_code === 'PARENT_RECEIVER_EXPIRED' ? 'timeout' : result.outcome, output: result, artifact: read.output_file, exit_code: completed?.raw_output?.exit_code ?? null, delivered: result.outcome === 'ready', reader_tool_use_id: read.reader_tool_use_id } + }, + async request(operation, { id, session }) { + if (operation === 'timeout') return { operation: '通常製品leaseの実期限まで待機', id: String(id), session, timeout_shortened: false } + fail('ACCEPTANCE_NATIVE_TASK_CONTROL_UNCONFIRMED', `${harness}/${operation}: 公式cancel/exit操作は未実測です`) + }, + }, + afterStartup: harness === 'grok' ? () => fixture.submit('/hooks-trust') : null, + async prepare() { + // 新規fixtureのproject設定のみ。通常HOMEのhook/MCP/provider/authには触れない。 + execFileSync('git', ['init', '--quiet', fixture.project]) + const tee = join(fixture.project, 'product-hook-tee.mjs') + writeFileSync(tee, `import {spawnSync} from 'node:child_process';import {appendFileSync} from 'node:fs';const chunks=[];for await(const c of process.stdin)chunks.push(c);const raw=Buffer.concat(chunks),result=spawnSync(process.execPath,[process.argv[3],process.argv[4]],{input:raw});const event=JSON.parse(raw.toString('utf8').replace(/^\\uFEFF/u,''));delete event.user_email;appendFileSync(process.argv[2],JSON.stringify({at:new Date().toISOString(),pid:process.pid,stdin_prefix_hex:raw.subarray(0,4).toString('hex'),event,stdout:result.stdout.toString('utf8'),stderr:result.stderr.toString('utf8'),code:result.status})+'\\n');process.stdout.write(result.stdout);process.stderr.write(result.stderr);process.exitCode=result.status??1;\n`, { mode: 0o600 }) + const command = platform.hookCommand(process.execPath, [tee, hookFile, join(pkg, 'skill/scripts/parent-hook.mjs'), harness]) + const rewrite = value => Array.isArray(value) ? value.map(rewrite) : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([key, child]) => [key, key === 'command' ? command : rewrite(child)])) : value + const owned = rewrite(hookEntries(harness)), registration = { ...parentRegistration(), env: { PEERTABLE_TOKEN_SOURCE_FILE: tokenFile } } + if (harness === 'cursor') { + fixture.modify(join(fixture.project, '.cursor/mcp.json'), () => Buffer.from(JSON.stringify({ mcpServers: { peertable_parent: registration } }) + '\n')) + fixture.modify(join(fixture.project, '.cursor/hooks.json'), () => Buffer.from(JSON.stringify({ version: 1, hooks: owned }) + '\n')) + } else { + fixture.modify(join(fixture.project, '.grok/config.toml'), () => Buffer.from(`[mcp_servers.peertable_parent]\ncommand = ${JSON.stringify(registration.command)}\nargs = ${JSON.stringify(registration.args)}\n[mcp_servers.peertable_parent.env]\nPEERTABLE_TOKEN_SOURCE_FILE = ${JSON.stringify(tokenFile)}\n`)) + fixture.modify(join(fixture.project, '.grok/hooks/peertable-parent.json'), () => Buffer.from(JSON.stringify({ hooks: owned }) + '\n')) + } + }, + hookFile, + } + }, + } + return adapters +} From 78116977bbef5bfd0707e1b32e248a223d6fd6c5 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:17:56 +0900 Subject: [PATCH 30/72] =?UTF-8?q?=E5=B0=82=E7=94=A8=E8=A6=AA=E3=81=AEWindo?= =?UTF-8?q?ws=E8=B5=B7=E5=8B=95=E3=82=92PowerShell7=E3=81=B8=E7=B5=B1?= =?UTF-8?q?=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scenarios-fixtures.mjs | 17 ++++++++---- .../scenarios-surfaces.mjs | 27 ++++++++++++++----- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/experiments/parent-product-acceptance/scenarios-fixtures.mjs b/experiments/parent-product-acceptance/scenarios-fixtures.mjs index b94fa5a9..c046d226 100644 --- a/experiments/parent-product-acceptance/scenarios-fixtures.mjs +++ b/experiments/parent-product-acceptance/scenarios-fixtures.mjs @@ -7,8 +7,9 @@ import { randomUUID } from 'node:crypto' import { pathToFileURL } from 'node:url' import { isDeepStrictEqual } from 'node:util' import { openAiterm } from './aiterm.mjs' -import { resolveCli, startupAction, readTranscript, transcriptPath } from './harness.mjs' +import { startupAction, readTranscript, transcriptPath } from './harness.mjs' import { parseDelivered, sha256 } from './evidence.mjs' +import { nativeInvocation, resolveOfficialCli } from './scenarios-surfaces.mjs' const fail = (code, message) => { throw Object.assign(new Error(message), { code }) } const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) @@ -65,7 +66,7 @@ export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUr const project = join(directory, 'project'); mkdirSync(join(project, '.team'), { recursive: true, mode: 0o700 }); writeJson(join(project, '.team/setup-state.json'), { room, server_url: serverUrl, mode: 'adhoc' }) const configuration = ordinaryConfiguration(harness) if (['claude', 'codex'].includes(harness) && !ownsParentConnection(harness)) fail('ACCEPTANCE_FIXTURE_PARENT_NOT_CONNECTED', '通常設定のPeertable所有登録が必要です。fixtureはglobal登録を変更しません') - let adapter = surfaceAdapters[harness] ?? (['claude', 'codex'].includes(harness) ? { resolveCli: () => resolveCli(harness), startup: view => startupAction(harness, view), transcript: session => transcriptPath(harness, session), read: file => readTranscript(harness, file) } : null) + let adapter = surfaceAdapters[harness] ?? (['claude', 'codex'].includes(harness) ? { resolveCli: () => resolveOfficialCli(harness, platform), startup: view => startupAction(harness, view), transcript: session => transcriptPath(harness, session), read: file => readTranscript(harness, file) } : null) const fixture = { directory, project, configuration, pkg, harness, name, room, pty: null, aiterm: null, target: null, session: null, owner: null, paneOwner: null, nativeOwners: [], receiverOwners: [], ready: false, backups: new Map(), closed: false, observations: [], sessionSettings, adapter } if (adapter?.bind) adapter = adapter.bind(fixture) fixture.adapter = adapter @@ -217,9 +218,10 @@ export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUr if (sessionSettings && harness === 'claude') { const settings = join(project, 'session-settings.json'); writeJson(settings, sessionSettings); argv.push('--settings', settings) } if (model) argv.push(harness === 'codex' ? '-m' : '--model', model) if (resume) argv.unshift(...(harness === 'codex' ? ['resume', resume] : harness === 'claude' ? ['--resume', resume] : adapter.resumeArgs(resume))) - writeJson(input, { executable: cli.executable, argv, project, tokenFile }) + const invocation = nativeInvocation(cli.executable, argv, { shellCommand: platform.shellCommand, interactive: true }) + writeJson(input, { executable: cli.executable, argv, invocation, project, tokenFile }) // HOME・認証・model/provider設定はharnessの通常環境のまま。token参照だけを試験roomへ渡す。 - writeFileSync(launcher, `import {readFileSync} from 'node:fs';import {spawn} from 'node:child_process';const p=JSON.parse(readFileSync(process.argv[2],'utf8'));const child=spawn(p.executable,p.argv,{cwd:p.project,stdio:'inherit',env:{...process.env,PEERTABLE_TOKEN_SOURCE_FILE:p.tokenFile},shell:process.platform==='win32'});child.on('exit',(code)=>process.exit(code??1));\n`, { mode: 0o600 }) + writeFileSync(launcher, `import {readFileSync} from 'node:fs';import {spawn} from 'node:child_process';const p=JSON.parse(readFileSync(process.argv[2],'utf8'));const child=spawn(p.invocation.executable,p.invocation.argv,{cwd:p.project,stdio:'inherit',env:{...process.env,PEERTABLE_TOKEN_SOURCE_FILE:p.tokenFile}});child.on('exit',(code)=>process.exit(code??1));\n`, { mode: 0o600 }) if (!fixture.aiterm) fixture.aiterm = await openAiterm() if (!fixture.pty) fixture.pty = await fixture.aiterm.open(`pt-fixture-${randomUUID().slice(0, 8)}`, process.platform === 'win32' ? 'pwsh' : undefined) await fixture.trackOwnProcesses() @@ -247,7 +249,12 @@ export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUr await fixture.trackOwnProcesses() if (!fixture.ready || !fixture.owner || !platform.sameProcess(fixture.owner)) return const exit = harness === 'claude' ? '/exit' : harness === 'codex' ? '/quit' : adapter.exit - if (!exit) return + if (!exit) { + await fixture.aiterm.close(fixture.pty); fixture.pty = null + await waitOwnedFixtureExit({ readEndpoints: () => projectEndpoints(project), sameProcess: platform.sameProcess, knownOwners: [...fixture.nativeOwners, ...fixture.receiverOwners, fixture.paneOwner].filter(Boolean), indexExists: id => existsSync(join(homedir(), '.peertable/parent-receivers/endpoints', `${id}.json`)) }) + fixture.ready = false; fixture.owner = null; fixture.paneOwner = null + return + } await fixture.submit(exit) await until('fixture親終了', () => !platform.sameProcess(fixture.owner), 30000) } diff --git a/experiments/parent-product-acceptance/scenarios-surfaces.mjs b/experiments/parent-product-acceptance/scenarios-surfaces.mjs index 2f8b7469..19d02877 100644 --- a/experiments/parent-product-acceptance/scenarios-surfaces.mjs +++ b/experiments/parent-product-acceptance/scenarios-surfaces.mjs @@ -1,11 +1,28 @@ // Cursor/Grok公式CLIの起動と試験project設定。会話/taskの観測はbackground-harness.mjsへ接続する。 -import { existsSync, writeFileSync } from 'node:fs' +import { existsSync, writeFileSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { homedir } from 'node:os' import { execFileSync } from 'node:child_process' import { pathToFileURL } from 'node:url' const fail = (code, message) => { throw Object.assign(new Error(message), { code }) } +// WindowsはPowerShell 7へargvを渡す。Nodeの既定shellを選ばず、対話stdinは呼出し元のstdioを継承する。 +export function nativeInvocation(executable, args, { platformName = process.platform, shellCommand, interactive = false } = {}) { + if (platformName !== 'win32') return { executable, argv: args } + const script = `$ErrorActionPreference='Stop'; ${shellCommand(executable, args, 'win32')}; exit $LASTEXITCODE` + return { executable: 'pwsh.exe', argv: ['-NoLogo', '-NoProfile', ...(interactive ? [] : ['-NonInteractive']), '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')] } +} + +export function resolveOfficialCli(harness, platform) { + const name = { claude: 'claude', codex: 'codex', cursor: 'cursor-agent', grok: 'grok' }[harness] + if (!name) fail('ACCEPTANCE_OFFICIAL_CLI_UNSUPPORTED', harness) + // CLIの公式shimもPowerShellからそのまま呼ぶ。内部native pathを推測しない。 + const executable = process.platform === 'win32' ? execFileSync('pwsh.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(`$ErrorActionPreference='Stop'; (Get-Command ${platform.psQuote(name)} -ErrorAction Stop).Source`, 'utf16le').toString('base64')], { encoding: 'utf8' }).trim() : platform.resolveExecutable(name) + const invocation = nativeInvocation(executable, ['--version'], { shellCommand: platform.shellCommand }) + const version = execFileSync(invocation.executable, invocation.argv, { encoding: 'utf8' }).trim().split(/\r?\n/u)[0] + return { executable, version } +} + export async function createBackgroundSurfaceAdapters({ pkg, tokenFile, backgroundObserverFactory }) { if (!backgroundObserverFactory) { try { backgroundObserverFactory = (await import('./background-harness.mjs')).createBackgroundHarnessObserver } @@ -13,12 +30,7 @@ export async function createBackgroundSurfaceAdapters({ pkg, tokenFile, backgrou } const { hookEntries, parentRegistration } = await import(pathToFileURL(join(pkg, 'skill/scripts/parent-connect.mjs')).href) const platform = await import(pathToFileURL(join(pkg, 'skill/scripts/parent-platform.mjs')).href) - const resolve = harness => { - const name = harness === 'cursor' ? 'cursor-agent' : 'grok' - const executable = platform.resolveExecutable(name) - const version = execFileSync(executable, ['--version'], { encoding: 'utf8', shell: process.platform === 'win32' }).trim().split(/\r?\n/u)[0] - return { executable, version } - } + const resolve = harness => resolveOfficialCli(harness, platform) const adapters = {} for (const harness of ['cursor', 'grok']) adapters[harness] = { bind(fixture) { @@ -79,6 +91,7 @@ export async function createBackgroundSurfaceAdapters({ pkg, tokenFile, backgrou fixture.modify(join(fixture.project, '.grok/hooks/peertable-parent.json'), () => Buffer.from(JSON.stringify({ hooks: owned }) + '\n')) } }, + hookEvents: () => existsSync(hookFile) ? readFileSync(hookFile, 'utf8').split('\n').filter(Boolean).map(line => JSON.parse(line)) : [], hookFile, } }, From ac710cc965c60fc809fcb7c24a93bc5c80ae8af0 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:20:05 +0900 Subject: [PATCH 31/72] =?UTF-8?q?Windows=E3=81=AEwatcher=E3=82=92Codex=20M?= =?UTF-8?q?CP=E3=81=AEJob=E5=A4=96=E3=81=B8WMI=E3=81=A7=E8=B5=B7=E5=8B=95?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodexはMCP serverをJob Objectへ入れ、/quitでJobごと終了させる。Nodeのspawn (detached有無・breakaway)ではwatcherも同時に死ぬため、Windowsだけ Win32_Process.Createでbootstrapを起動し、bootstrapがwatcherを同じwatch.logへ 結んだdetached子として起動してpidだけ返す。envはargvに載せずpwsh 7のstdin経由で Win32_ProcessStartup.EnvironmentVariablesへ渡す。他OSは現行のNode spawnのまま。 Co-Authored-By: Claude Sonnet 5.5 --- skill/scripts/parent-process.mjs | 62 +++++++++++++++++++++++++++ skill/scripts/parent-process.test.mjs | 44 +++++++++++++++++++ skill/scripts/parent-runtime.mjs | 30 ++++++------- 3 files changed, 120 insertions(+), 16 deletions(-) create mode 100644 skill/scripts/parent-process.mjs create mode 100644 skill/scripts/parent-process.test.mjs diff --git a/skill/scripts/parent-process.mjs b/skill/scripts/parent-process.mjs new file mode 100644 index 00000000..3b9c5a55 --- /dev/null +++ b/skill/scripts/parent-process.mjs @@ -0,0 +1,62 @@ +// 親のwatcherを、親harnessのプロセス終了処理(Job Object等)の外で起動する。 +// OS差は起動APIへの適合だけに置く。起動後のlog・stdin・env・cwd・終了の契約は全OS共通。 +import { spawn, spawnSync } from 'node:child_process' +import { openSync, closeSync, existsSync, rmSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { failure, readJson, atomicJson, windowsArgumentList } from './parent-platform.mjs' + +const self = fileURLToPath(import.meta.url) +const pause = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) + +// CodexはMCP serverをJob Objectへ入れ、/quitでJobごと終了させる。Nodeのspawnは子をJobへ残すため、 +// WMI Win32_Process.CreateでJob外にbootstrapを起動し、bootstrapがwatcherをdetached起動する。 +// envはargvへ載せず、pwsh 7のstdinからWin32_ProcessStartup.EnvironmentVariablesへ渡す。 +const wmiCreate = ` +$ErrorActionPreference = 'Stop' +$reader = [IO.StreamReader]::new([Console]::OpenStandardInput(), [Text.UTF8Encoding]::new($false)) +$spec = $reader.ReadToEnd() | ConvertFrom-Json +$startup = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ ShowWindow = [uint16]0; CreateFlags = [uint32]0x400; EnvironmentVariables = [string[]]@($spec.env) } +$result = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = $spec.commandLine; CurrentDirectory = $spec.cwd; ProcessStartupInformation = $startup } +@{ return_value = [int]$result.ReturnValue; pid = [int]$result.ProcessId } | ConvertTo-Json -Compress +` + +// 起動したwatcherのpidを返す。logFileへstdout/stderrを追記し、stdinは閉じる。 +export function launchDetached({ executable, args, cwd, env, logFile, onError }) { + if (process.platform !== 'win32') { + const fd = openSync(logFile, 'a', 0o600) + try { + const child = spawn(executable, args, { cwd, detached: true, stdio: ['ignore', fd, fd], env }) + child.on('error', onError) + child.unref() + return child.pid + } finally { closeSync(fd) } + } + const handshake = join(resolve(logFile, '..'), 'watch.launch.json') + rmSync(handshake, { force: true }) + const commandLine = windowsArgumentList([process.execPath, self, '--bootstrap', handshake, logFile, cwd, executable, ...args]) + const input = JSON.stringify({ commandLine, cwd, env: Object.entries(env).filter(([name, value]) => value !== undefined && !name.startsWith('=')).map(([name, value]) => `${name}=${value}`) }) + const run = spawnSync('pwsh.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(wmiCreate, 'utf16le').toString('base64')], { input, encoding: 'utf8', windowsHide: true }) + if (run.error?.code === 'ENOENT') throw failure('PARENT_PWSH7_MISSING') + if (run.error || run.status !== 0) throw failure('PARENT_WATCH_START_FAILED', `WMI起動に失敗しました: ${run.error?.message ?? run.stderr.trim()}`) + const created = JSON.parse(run.stdout) + if (created.return_value !== 0) throw failure('PARENT_WATCH_START_FAILED', `Win32_Process.Createが${created.return_value}を返しました`) + const deadline = Date.now() + 15000 + while (!existsSync(handshake)) { + if (Date.now() >= deadline) throw failure('PARENT_WATCH_START_FAILED', `bootstrap ${created.pid}の起動応答がありません`) + pause(50) + } + const answer = readJson(handshake) + rmSync(handshake, { force: true }) + if (answer.error) throw failure('PARENT_WATCH_START_FAILED', answer.error) + return answer.pid +} + +// Windows bootstrap: Job外で起動され、watcherをlogへ結んだdetached子として起動してpidだけ返して終わる。 +if (process.argv[2] === '--bootstrap' && process.argv[1] && resolve(process.argv[1]) === self) { + const [handshake, logFile, cwd, executable, ...args] = process.argv.slice(3) + const fd = openSync(logFile, 'a', 0o600) + const child = spawn(executable, args, { cwd, detached: true, windowsHide: true, stdio: ['ignore', fd, fd], env: process.env }) + child.once('error', error => { atomicJson(handshake, { error: error.message }); process.exit(1) }) + child.once('spawn', () => { atomicJson(handshake, { pid: child.pid }); child.unref(); process.exit(0) }) +} diff --git a/skill/scripts/parent-process.test.mjs b/skill/scripts/parent-process.test.mjs new file mode 100644 index 00000000..157220d0 --- /dev/null +++ b/skill/scripts/parent-process.test.mjs @@ -0,0 +1,44 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync, realpathSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { setTimeout as delay } from 'node:timers/promises' +import { launchDetached } from './parent-process.mjs' +import { processIdentity, sameProcess } from './parent-platform.mjs' + +// 全OS共通の契約: env全量・cwd・argv・stdin無効・同じlogへのstdout/stderr・pid+開始identityでの生死。 +test('launchDetachedはenv/cwd/argv/stdin/logの契約をOS間で同じに保つ', async t => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'peertable-launch-'))), work = join(root, '作業 dir'), log = join(root, 'watch.log') + mkdirSync(work) + const probe = join(root, 'probe.mjs'), report = join(root, 'report.json') + writeFileSync(probe, ` +import { writeFileSync } from 'node:fs' +const stdinEnded = await new Promise(resolve => { process.stdin.on('end', () => resolve(true)); process.stdin.on('error', () => resolve(true)); process.stdin.resume(); setTimeout(() => resolve(false), 2000) }) +console.log('stdout-line'); console.error('stderr-line') +writeFileSync(${JSON.stringify(report)}, JSON.stringify({ argv: process.argv.slice(2), cwd: process.cwd(), env: process.env, stdinEnded })) +setInterval(() => {}, 1000) +`) + const args = [probe, 'a b', 'q"uote', '日本語', 'tail\\', "it's"] + const env = { ...process.env, PEERTABLE_CREDENTIAL_FILE: join(root, 'cred file'), PEERTABLE_WATCH_NO_STDIN: '1', PT_LAUNCH_VALUE: '日本語 "値" \'x\' %PATH%' } + const pid = launchDetached({ executable: process.execPath, args, cwd: work, env, logFile: log, onError: error => { throw error } }) + let identity + t.after(() => { try { process.kill(pid) } catch {} rmSync(root, { recursive: true, force: true }) }) + const deadline = Date.now() + 20000 + while (!existsSync(report) && Date.now() < deadline) await delay(50) + const seen = JSON.parse(readFileSync(report, 'utf8')) + assert.deepEqual(seen.argv, args.slice(1)) + assert.equal(seen.cwd.replaceAll('\\', '/').toLowerCase(), work.replaceAll('\\', '/').toLowerCase()) + assert.equal(seen.stdinEnded, true) + for (const [name, value] of Object.entries(env)) if (!name.startsWith('=')) assert.equal(seen.env[name], value, `env ${name}`) + const text = readFileSync(log, 'utf8') + assert.match(text, /stdout-line/u); assert.match(text, /stderr-line/u) + identity = processIdentity(pid) + assert.equal(identity.pid, pid) + assert.ok(sameProcess(identity)) + process.kill(pid) + const stop = Date.now() + 10000 + while (sameProcess(identity) && Date.now() < stop) await delay(50) + assert.equal(sameProcess(identity), false) + assert.equal(existsSync(join(root, 'watch.launch.json')), false) +}) diff --git a/skill/scripts/parent-runtime.mjs b/skill/scripts/parent-runtime.mjs index 2db1ac83..33daccde 100644 --- a/skill/scripts/parent-runtime.mjs +++ b/skill/scripts/parent-runtime.mjs @@ -1,7 +1,7 @@ // 親runtimeの起動・移行・診断・停止。親harnessそのものは停止しない。 -import { existsSync, readFileSync, openSync, closeSync, rmSync, readdirSync, realpathSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync, rmSync, readdirSync, realpathSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { spawn, execFileSync } from 'node:child_process' +import { execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' import { setTimeout as delay } from 'node:timers/promises' import { randomUUID } from 'node:crypto' @@ -9,6 +9,7 @@ import { ParentSpool } from './parent-delivery.mjs' import { atomicJson, readJson, processIdentity, sameProcess, failure, posixQuote, psQuote } from './parent-platform.mjs' import { registerEndpoint, endpointsFor, forgetEndpoint } from './parent-caller.mjs' import { ownsParentConnection } from './parent-connect.mjs' +import { launchDetached } from './parent-process.mjs' import { RoomApi } from './room-api.mjs' import { runtimeDigest } from './runtime-digest.mjs' @@ -102,21 +103,18 @@ export async function startEndpoint(spool) { await stopOwnedProcess(state.waiter.owner) } spool.update({ watcher: null, runtime_digest: currentDigest }) - const fd = openSync(join(spool.root, 'watch.log'), 'a', 0o600) - try { - spool.transact(saved => { - if (saved.watcher && sameProcess(saved.watcher)) return - const child = spawn(process.execPath, [watcherEntry, spool.project, state.name, '--deliver', spool.id], { - cwd: spool.project, detached: process.platform !== 'win32', stdio: ['ignore', fd, fd], - env: { ...process.env, PEERTABLE_CREDENTIAL_FILE: state.credential, PEERTABLE_WATCH_NO_STDIN: '1' }, - }) - child.on('error', error => { spool.update({ runtime: 'failed', error_code: 'PARENT_WATCH_START_FAILED', error_detail: error.message }) }) - child.unref() - const identity = processIdentity(child.pid) - if (!identity) throw failure('PARENT_WATCH_START_FAILED') - saved.watcher = identity + spool.transact(saved => { + if (saved.watcher && sameProcess(saved.watcher)) return + const pid = launchDetached({ + executable: process.execPath, args: [watcherEntry, spool.project, state.name, '--deliver', spool.id], cwd: spool.project, + env: { ...process.env, PEERTABLE_CREDENTIAL_FILE: state.credential, PEERTABLE_WATCH_NO_STDIN: '1' }, + logFile: join(spool.root, 'watch.log'), + onError: error => { spool.update({ runtime: 'failed', error_code: 'PARENT_WATCH_START_FAILED', error_detail: error.message }) }, }) - } finally { closeSync(fd) } + const identity = processIdentity(pid) + if (!identity) throw failure('PARENT_WATCH_START_FAILED') + saved.watcher = identity + }) const probe = spool.read().probe_id ?? randomUUID() spool.update({ probe_id: probe }) spool.saveEvent({ schema: 'peertable.parent-watch-event.v1', type: 'parent_probe', event_id: `probe:${probe}`, room: state.room, from: 'peertable', to: state.name, body: `配送確認の符号は ${probe}。` }) From 0a361f7255b96172c9e08ed32367625dcef9e986 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:26:36 +0900 Subject: [PATCH 32/72] =?UTF-8?q?watcher=E8=B5=B7=E5=8B=95=E3=81=AE?= =?UTF-8?q?=E5=A4=96=E9=83=A8=E5=BF=9C=E7=AD=94=E3=81=A8=E8=B5=B7=E5=8B=95?= =?UTF-8?q?=E5=A4=B1=E6=95=97=E3=82=92typed=20error=E3=81=A7=E6=98=8E?= =?UTF-8?q?=E7=A4=BA=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WMIの応答がJSONでない・return_value/pidが欠ける・非0の場合と、bootstrapの handshakeにpidが無い場合をPARENT_WATCH_START_FAILEDにする。bootstrapはlog open・ cwd・executableの起動失敗をhandshakeへtyped失敗として書き、fdを閉じて終了する。 lock/timeoutは変更しない。 Co-Authored-By: Claude Sonnet 5.5 --- skill/scripts/parent-process.mjs | 39 ++++++++++++++++++++------- skill/scripts/parent-process.test.mjs | 24 ++++++++++++++++- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/skill/scripts/parent-process.mjs b/skill/scripts/parent-process.mjs index 3b9c5a55..5c696264 100644 --- a/skill/scripts/parent-process.mjs +++ b/skill/scripts/parent-process.mjs @@ -21,6 +21,25 @@ $result = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Argument @{ return_value = [int]$result.ReturnValue; pid = [int]$result.ProcessId } | ConvertTo-Json -Compress ` +const startFailed = detail => failure('PARENT_WATCH_START_FAILED', detail) +const validPid = value => Number.isSafeInteger(value) && value > 0 +// 外部(pwsh/WMI)の応答境界。壊れた応答・欠けた項目・非0の戻り値は起動失敗として明示する。 +export function parseWmiResult(stdout) { + let created + try { created = JSON.parse(stdout) } catch { throw startFailed(`WMIの応答がJSONではありません: ${String(stdout).slice(0, 200)}`) } + if (!Number.isSafeInteger(created?.return_value) || !validPid(created?.pid)) throw startFailed(`WMIの応答にreturn_value/pidがありません: ${String(stdout).slice(0, 200)}`) + if (created.return_value !== 0) throw startFailed(`Win32_Process.Createが${created.return_value}を返しました`) + return created +} +// bootstrapが書くhandshake(外部processの出力)。pidか、typedな起動失敗のどちらかだけを受ける。 +function readHandshake(file) { + let answer + try { answer = readJson(file) } catch (error) { throw startFailed(`bootstrapの応答を読めません: ${error.message}`) } + if (typeof answer?.error === 'string') throw startFailed(answer.error) + if (!validPid(answer?.pid)) throw startFailed(`bootstrapの応答にpidがありません: ${JSON.stringify(answer)}`) + return answer.pid +} + // 起動したwatcherのpidを返す。logFileへstdout/stderrを追記し、stdinは閉じる。 export function launchDetached({ executable, args, cwd, env, logFile, onError }) { if (process.platform !== 'win32') { @@ -39,24 +58,26 @@ export function launchDetached({ executable, args, cwd, env, logFile, onError }) const run = spawnSync('pwsh.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(wmiCreate, 'utf16le').toString('base64')], { input, encoding: 'utf8', windowsHide: true }) if (run.error?.code === 'ENOENT') throw failure('PARENT_PWSH7_MISSING') if (run.error || run.status !== 0) throw failure('PARENT_WATCH_START_FAILED', `WMI起動に失敗しました: ${run.error?.message ?? run.stderr.trim()}`) - const created = JSON.parse(run.stdout) - if (created.return_value !== 0) throw failure('PARENT_WATCH_START_FAILED', `Win32_Process.Createが${created.return_value}を返しました`) + const created = parseWmiResult(run.stdout) const deadline = Date.now() + 15000 while (!existsSync(handshake)) { if (Date.now() >= deadline) throw failure('PARENT_WATCH_START_FAILED', `bootstrap ${created.pid}の起動応答がありません`) pause(50) } - const answer = readJson(handshake) + const answer = readHandshake(handshake) rmSync(handshake, { force: true }) - if (answer.error) throw failure('PARENT_WATCH_START_FAILED', answer.error) - return answer.pid + return answer } // Windows bootstrap: Job外で起動され、watcherをlogへ結んだdetached子として起動してpidだけ返して終わる。 if (process.argv[2] === '--bootstrap' && process.argv[1] && resolve(process.argv[1]) === self) { const [handshake, logFile, cwd, executable, ...args] = process.argv.slice(3) - const fd = openSync(logFile, 'a', 0o600) - const child = spawn(executable, args, { cwd, detached: true, windowsHide: true, stdio: ['ignore', fd, fd], env: process.env }) - child.once('error', error => { atomicJson(handshake, { error: error.message }); process.exit(1) }) - child.once('spawn', () => { atomicJson(handshake, { pid: child.pid }); child.unref(); process.exit(0) }) + const fail = (fd, error) => { if (fd !== undefined) closeSync(fd); atomicJson(handshake, { error: error.message }); process.exit(1) } + let fd + try { fd = openSync(logFile, 'a', 0o600) } catch (error) { fail(undefined, error) } + try { + const child = spawn(executable, args, { cwd, detached: true, windowsHide: true, stdio: ['ignore', fd, fd], env: process.env }) + child.once('error', error => fail(fd, error)) + child.once('spawn', () => { closeSync(fd); atomicJson(handshake, { pid: child.pid }); child.unref(); process.exit(0) }) + } catch (error) { fail(fd, error) } } diff --git a/skill/scripts/parent-process.test.mjs b/skill/scripts/parent-process.test.mjs index 157220d0..7bc43691 100644 --- a/skill/scripts/parent-process.test.mjs +++ b/skill/scripts/parent-process.test.mjs @@ -4,7 +4,9 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync import { join } from 'node:path' import { tmpdir } from 'node:os' import { setTimeout as delay } from 'node:timers/promises' -import { launchDetached } from './parent-process.mjs' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { launchDetached, parseWmiResult } from './parent-process.mjs' import { processIdentity, sameProcess } from './parent-platform.mjs' // 全OS共通の契約: env全量・cwd・argv・stdin無効・同じlogへのstdout/stderr・pid+開始identityでの生死。 @@ -42,3 +44,23 @@ setInterval(() => {}, 1000) assert.equal(sameProcess(identity), false) assert.equal(existsSync(join(root, 'watch.launch.json')), false) }) + +test('WMIの壊れた/欠けた/非0応答はPARENT_WATCH_START_FAILEDになる', () => { + for (const raw of ['not json', '', '{}', '{"return_value":0}', '{"pid":5}', '{"return_value":"0","pid":5}', '{"return_value":0,"pid":0}', 'null', '{"return_value":9,"pid":5}']) { + assert.throws(() => parseWmiResult(raw), { code: 'PARENT_WATCH_START_FAILED' }, JSON.stringify(raw)) + } + assert.equal(parseWmiResult('{"return_value":0,"pid":5}').pid, 5) +}) + +// bootstrapの起動失敗(log open/cwd/executable)はhandshakeへtyped失敗を書いて終了する。 +test('bootstrapは起動失敗をhandshakeへ書いて終了する', () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'peertable-bootstrap-'))) + try { + for (const [label, log, cwd, executable] of [['open', join(root, 'nodir', 'w.log'), root, process.execPath], ['cwd', join(root, 'w.log'), join(root, 'nocwd'), process.execPath], ['exe', join(root, 'w.log'), root, join(root, 'noexe')]]) { + const handshake = join(root, `${label}.json`) + const run = spawnSync(process.execPath, [fileURLToPath(new URL('./parent-process.mjs', import.meta.url)), '--bootstrap', handshake, log, cwd, executable, '-e', '0'], { encoding: 'utf8', timeout: 15000 }) + assert.equal(run.status, 1, `${label}: ${run.stderr}`) + assert.equal(typeof JSON.parse(readFileSync(handshake, 'utf8')).error, 'string', label) + } + } finally { rmSync(root, { recursive: true, force: true }) } +}) From 98353b9891276216d9f0642884497218a342a8fd Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:29:29 +0900 Subject: [PATCH 33/72] =?UTF-8?q?=E7=8B=AC=E7=AB=8Bwatcher=E3=81=AE?= =?UTF-8?q?=E6=9C=AC=E4=BA=BA=E7=85=A7=E5=90=88=E3=81=A8=E8=83=8C=E6=99=AF?= =?UTF-8?q?=E8=A6=AA=E3=81=AE=E5=85=AC=E5=BC=8F=E7=B5=82=E4=BA=86=E8=A6=B3?= =?UTF-8?q?=E6=B8=AC=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scenarios-context.mjs | 25 +++++++- .../scenarios-fixtures.mjs | 4 +- .../scenarios-native.mjs | 59 ++++++++++++++----- .../scenarios-native.test.mjs | 18 +++++- .../scenarios.test.mjs | 12 +++- 5 files changed, 96 insertions(+), 22 deletions(-) diff --git a/experiments/parent-product-acceptance/scenarios-context.mjs b/experiments/parent-product-acceptance/scenarios-context.mjs index 0acbc41c..05bd205a 100644 --- a/experiments/parent-product-acceptance/scenarios-context.mjs +++ b/experiments/parent-product-acceptance/scenarios-context.mjs @@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto' import { judgeAudience, sha256 } from './evidence.mjs' import { isDeepStrictEqual } from 'node:util' import { scenarioPlan, scenarioNames } from './scenarios.mjs' +import { readJsonl } from './harness.mjs' const fail = (code, detail) => { throw Object.assign(new Error(detail), { code }) } const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) @@ -147,8 +148,14 @@ export function nativeReceiveReaderAllowed(use, registrations, tasks, session) { return registration.native_task.input?.name === 'run_terminal_command' && use.name === 'get_command_or_subagent_output' && isDeepStrictEqual(use.input, { task_ids: [registration.native_task.id] }) } +// 公式completion hookのCID/generationと対象last replyを照合する。UIのfooterだけでは完了にしない。 +export function cursorIdleCompletion(events, last, session) { + if (!last || last.session !== session || !last.turn_id) fail('ACCEPTANCE_IDLE_NATIVE_END_MISSING', '対象Cursor応答の会話/generationがありません') + return events.find(row => row.event?.hook_event_name === 'stop' && row.event.status === 'completed' && row.event.conversation_id === session && row.event.generation_id === last.turn_id) ?? null +} + export async function createScenarioContext(options) { - const { meta, spool, api, observe, submit, file, pkg, projectDir, privateDir, proxy, bin, nativeActions = {}, screen } = options + const { meta, spool, api, observe, submit, file, pkg, projectDir, privateDir, proxy, bin, nativeActions = {}, screen, nativeStopFile } = options const importInstalled = name => import(pathToFileURL(join(pkg, 'skill/scripts', name)).href) const { PAGE_CHARS } = await importInstalled('parent-delivery.mjs') const { sameProcess, processIdentity, processDescendsFrom, shellCommand } = await importInstalled('parent-platform.mjs') @@ -286,14 +293,26 @@ export async function createScenarioContext(options) { const last = seen.replies.at(-1) await until('実親idle', async () => { const view = await screen(), current = observe() - const prompt = meta.harness === 'claude' ? /^\s*❯\s/mu : /^\s*›\s/mu + if (meta.harness === 'cursor') { + if (!nativeStopFile) fail('ACCEPTANCE_IDLE_NATIVE_END_MISSING', 'Cursorの専用stop hook保存先がありません') + const events = existsSync(nativeStopFile) ? readJsonl(readFileSync(nativeStopFile, 'utf8'), nativeStopFile).rows.map(item => item.row) : [] + const completed = cursorIdleCompletion(events, last, meta.parent_session) + if (!completed || sameProcess(processIdentity(completed.pid))) return null + faultState.set(`${scope.runId}:idle_stop`, completed) + return current.replies.length === count && current.replies.at(-1)?.turn_id === last.turn_id && /Add a follow-up/u.test(view) + } + if (meta.harness === 'grok') { + const ended = readJsonl(readFileSync(file, 'utf8'), file).rows.map(item => item.row.params?.update).filter(update => update?.sessionUpdate === 'turn_completed').at(-1) + if (ended?.prompt_id !== last.turn_id) return null + } + const prompt = ['claude', 'grok'].includes(meta.harness) ? /^\s*❯\s/mu : /^\s*›\s/mu return prompt.test(view) && current.replies.at(-1)?.order === last.order }, 120000, 1000) // promptが作業中にも表示される実装があるため、native task終了の記録も必要にする。 const ended = meta.harness === 'codex' ? codexTurns(file).get(last.turn_id) : null if (meta.harness === 'codex' && (!ended || ended.end_kind !== 'task_complete' || ended.completed_order < last.order)) fail('ACCEPTANCE_IDLE_NATIVE_END_MISSING', '対象last turnのtask_completeを確認できません') faultState.set(`${scope.runId}:idle`, { turn: last.turn_id, order: last.order, rows: seen.rows }) - return result(scope, 'idle_without_input', 'harness_transcript', { idle_turn: last.turn_id, reply_order: last.order }) + return result(scope, 'idle_without_input', 'harness_transcript', { idle_turn: last.turn_id, reply_order: last.order, official_stop: faultState.get(`${scope.runId}:idle_stop`) ?? null }) } actions.idle_observe = async (_, scope) => { const before = faultState.get(`${scope.runId}:idle`), latest = scope.checks.at(-1) diff --git a/experiments/parent-product-acceptance/scenarios-fixtures.mjs b/experiments/parent-product-acceptance/scenarios-fixtures.mjs index c046d226..a4e5f6de 100644 --- a/experiments/parent-product-acceptance/scenarios-fixtures.mjs +++ b/experiments/parent-product-acceptance/scenarios-fixtures.mjs @@ -186,7 +186,7 @@ export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUr const nativeEvents = events ?? (harness === 'cursor' ? ['preToolUse', 'postToolUse', 'afterMCPExecution'] : ['PreToolUse', 'PostToolUse', 'Stop']) fixture.modify(file, before => { const value = before ? JSON.parse(before.toString('utf8').replace(/^\uFEFF/u, '')) : { ...(harness === 'cursor' ? { version: 1 } : {}), hooks: {} } - for (const event of nativeEvents) { const entry = harness === 'cursor' ? { command } : { hooks: [{ type: 'command', command, timeout: 86400 }] }; value.hooks[event] = [...(value.hooks[event] ?? []), entry] } + for (const event of nativeEvents) { const entry = harness === 'cursor' ? { command, timeout: 86400 } : { hooks: [{ type: 'command', command, timeout: 86400 }] }; value.hooks[event] = [...(value.hooks[event] ?? []), entry] } return Buffer.from(JSON.stringify(value, null, 2) + '\n') }) if (harness === 'codex') await fixture.trustProjectHooks() @@ -240,7 +240,7 @@ export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUr const meta = { ...sourceMeta, harness, room, parent_session: fixture.session, parent_process: fixture.owner, endpoint_id: spool.id, harness_version: adapter.resolveCli().version } const sessionAtJoin = fixture.session const boundObserve = () => { const seen = adapter.read(file, { session: sessionAtJoin, spool }); return seen.deliveries ? seen : { ...seen, deliveries: seen.injections.flatMap(row => parseDelivered(row.text).map(delivery => ({ ...delivery, order: row.order, session: row.session ?? sessionAtJoin, turn_id: row.turn_id, hook: row.hook, boundary: { encoding: row.encoding ?? 'none' } }))), replies: seen.replies.map(reply => ({ ...reply, session: reply.session ?? sessionAtJoin })) } } - fixture.target = { meta, spool, api: apiFor(room), observe: boundObserve, submit: fixture.submit, file, screen: () => fixture.aiterm.screen(fixture.pty), fixture } + fixture.target = { meta, spool, api: apiFor(room), observe: boundObserve, submit: fixture.submit, file, nativeStopFile: fixture.observer?.observations, screen: () => fixture.aiterm.screen(fixture.pty), fixture } return fixture.target } fixture.join = async (previousEndpoint = null) => { await fixture.submit(`Peertable parent_joinをproject=${project} name=${name}で1回呼んでください。同じ会話のroom配送の確認符号を原文のまま報告し、受信を継続してください。Cursor/Grokの背景登録とparent_readは製品receiptの完成済み入力を公式toolへ渡してください。`); return fixture.refreshTarget(previousEndpoint) } diff --git a/experiments/parent-product-acceptance/scenarios-native.mjs b/experiments/parent-product-acceptance/scenarios-native.mjs index a925d37a..05992773 100644 --- a/experiments/parent-product-acceptance/scenarios-native.mjs +++ b/experiments/parent-product-acceptance/scenarios-native.mjs @@ -8,7 +8,7 @@ import { execFileSync } from 'node:child_process' import { createScenarioContext } from './scenarios-context.mjs' import { createNativeFixtureFactory, waitOwnedFixtureExit } from './scenarios-fixtures.mjs' import { sha256 } from './evidence.mjs' -import { createBackgroundSurfaceAdapters } from './scenarios-surfaces.mjs' +import { createBackgroundSurfaceAdapters, nativeInvocation } from './scenarios-surfaces.mjs' const fail = (code, message, detail) => { throw Object.assign(new Error(message), { code, detail }) } const rows = file => existsSync(file) ? readFileSync(file, 'utf8').split('\n').filter(Boolean).map(line => JSON.parse(line)) : [] @@ -106,18 +106,32 @@ export function verifyRelatedIsolation({ primary, secondary, checks, messages }) return { primary: { room: primary.meta.room, session: primary.meta.parent_session, endpoint: primary.spool.id }, secondary: { room: secondary.meta.room, session: secondary.meta.parent_session, endpoint: secondary.spool.id } } } -// OS適合はここだけ。対象PID+開始identityを毎回照合し、native親自身や共有受信を停止しない。 -export async function pauseOwnedReceiver(owner, { parent, sameProcess, processDescendsFrom, paused, resume = false }) { - if (!owner || !sameProcess(owner) || owner.pid === parent.pid || !processDescendsFrom(owner, parent)) fail('ACCEPTANCE_PROCESS_FAULT_OWNER', '専用親の受信processではありません') +// 独立watcherの所有権は親子treeで推測せず、own endpointの保存identityとcaller本人で確認する。 +export function assertOwnedReceiver(owner, { target, fixture, sameProcess, processDescendsFrom, endpoints }) { + const parent = target?.meta.parent_process, current = target?.spool.read() + const sameIdentity = (a, b) => a && b && a.pid === b.pid && a.started === b.started + if (!fixture || target.fixture !== fixture || target.spool.project !== fixture.project || target.spool.id !== current?.endpoint_id || !parent?.started || !owner?.started || owner.pid === parent.pid || !sameProcess(parent) || !sameProcess(owner)) fail('ACCEPTANCE_PROCESS_FAULT_OWNER', '専用fixtureの存命親/endpoint/受信identityではありません') + if (current.harness !== fixture.harness || current.caller?.harness !== fixture.harness || current.caller?.conversation !== target.meta.parent_session || !sameIdentity(current.caller?.owner, parent)) fail('ACCEPTANCE_PROCESS_FAULT_CALLER', '受信endpointのcallerと実会話/親本人が一致しません') + const role = sameIdentity(current.watcher, owner) ? 'watcher' : sameIdentity(current.waiter?.owner, owner) ? 'waiter' : null + if (!role || role === 'waiter' && !processDescendsFrom(owner, parent)) fail('ACCEPTANCE_PROCESS_FAULT_OWNER', 'own spoolの受信processではありません') + const shared = endpoints.filter(endpoint => endpoint.id !== target.spool.id && endpoint.read().runtime !== 'stopped' && [endpoint.read().watcher, endpoint.read().waiter?.owner].some(other => sameIdentity(other, owner))) + if (shared.length) fail('ACCEPTANCE_PROCESS_FAULT_SHARED', '他endpointと共有する受信processは停止しません', { endpoints: shared.map(endpoint => endpoint.id) }) + return { endpoint_id: target.spool.id, project: fixture.project, parent_session: target.meta.parent_session, caller_owner: current.caller.owner, receiver_role: role, receiver_owner: owner } +} + +// OS適合はここだけ。公式spoolのPID+開始identityを操作直前に照合し、任意PIDへ作用しない。 +export async function pauseOwnedReceiver(owner, { target, fixture, sameProcess, processDescendsFrom, paused, resume = false }) { + const { endpointsFor } = await import(pathToFileURL(join(fixture.pkg, 'skill/scripts/parent-caller.mjs')).href) + const proof = assertOwnedReceiver(owner, { target, fixture, sameProcess, processDescendsFrom, endpoints: endpointsFor() }) if (process.platform !== 'win32') process.kill(owner.pid, resume ? 'SIGCONT' : 'SIGSTOP') else { // Windows公式thread API。列挙するのは検証済みown PIDのthreadだけ。 const script = `Add-Type -TypeDefinition 'using System;using System.Runtime.InteropServices;public class PeertableOwnThreads{[DllImport("kernel32.dll")]public static extern IntPtr OpenThread(uint a,bool b,uint c);[DllImport("kernel32.dll")]public static extern uint SuspendThread(IntPtr h);[DllImport("kernel32.dll")]public static extern uint ResumeThread(IntPtr h);[DllImport("kernel32.dll")]public static extern bool CloseHandle(IntPtr h);}';$p=Get-Process -Id ${owner.pid};foreach($t in $p.Threads){$h=[PeertableOwnThreads]::OpenThread(2,$false,$t.Id);if($h -eq [IntPtr]::Zero){throw 'own thread open failed'};try{$r=[PeertableOwnThreads]::${resume ? 'ResumeThread' : 'SuspendThread'}($h);if($r -eq 4294967295){throw 'own thread control failed'}}finally{[void][PeertableOwnThreads]::CloseHandle($h)}}` - execFileSync('pwsh.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { stdio: 'pipe' }) + execFileSync('pwsh.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { stdio: 'pipe' }) } - if (resume) paused.delete(owner.pid); else paused.set(owner.pid, owner) + if (resume) paused.delete(owner.pid); else paused.set(owner.pid, { owner, target, fixture }) if (!sameProcess(owner)) fail('ACCEPTANCE_PROCESS_FAULT_DISAPPEARED', '一時停止/再開の対象が終了しました') - return { owner, operation: resume ? 'resume' : 'suspend', at: new Date().toISOString() } + return { ...proof, operation: resume ? 'resume' : 'suspend', at: new Date().toISOString() } } // 残る境界を呼出し側の任意callbackへ押し出さず、このmodule内の公式操作に固定する。 @@ -177,7 +191,7 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { const before = observerRows(fixture).length state(s).final = { fixture, release, before, nonce } await fixture.submit(`最終応答と終了境界の試験です。toolを追加せず、符号${nonce}を含む短い最終応答をしてください。`) - const event = await s.until('実Stop hookの到着', () => observerRows(fixture).slice(before).find(row => row.event.hook_event_name === 'Stop' && nativeSession(row.event) === self(s).meta.parent_session), 120000, 50) + const event = await s.until('実Stop hookの到着', () => observerRows(fixture).slice(before).find(row => ['Stop', 'stop'].includes(row.event.hook_event_name) && (s.harness !== 'cursor' || row.event.status === 'completed') && nativeSession(row.event) === self(s).meta.parent_session), 120000, 50) if (!s.sameProcess(s.processIdentity(event.pid))) fail('ACCEPTANCE_FINAL_HOOK_NOT_LIVE', '公式Stopの実processがありません') state(s).final.event = event // 専用observerは同時起動した所有hookを妨げない。配送を挟んで終了するraceを作る。 @@ -243,6 +257,18 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { actions.session_clear = async (_, s) => { const old = self(s), fixture = fixtureFor(old), before = observerRows(fixture).length s.context.registerEndpoint('old', old) + if (['cursor', 'grok'].includes(s.harness)) { + const beforeEvents = fixture.adapter.hookEvents().length + await fixture.stop(); await fixture.launch() + const marker = `NEW_SESSION_${randomUUID()}`, command = s.shellCommand(process.execPath, ['-e', `console.log(${JSON.stringify(marker)})`]) + const input = s.harness === 'cursor' ? { command, cwd: fixture.project } : { command, description: '専用新会話の実相関確認', background: false, timeout: 0 } + await fixture.submit(`新会話の実相関試験です。${s.harness === 'cursor' ? 'Shell' : 'run_terminal_command'}へ次の入力を渡し、出力をそのまま報告してください。Peertable joinはまだ呼ばないでください。input: ${JSON.stringify(input)}`) + const event = await s.until('新CLIの実hook CID', () => fixture.adapter.hookEvents().slice(beforeEvents).find(row => nativeSession(row.event) && nativeSession(row.event) !== old.meta.parent_session && JSON.stringify(row.event).includes(marker)), 120000, 100) + const newSession = nativeSession(event.event), file = await s.until('新会話の公式transcript', () => fixture.adapter.transcript(newSession), 30000, 100) + const reply = await s.until('新CLIの実会話応答', () => fixture.adapter.read(file, { session: newSession }).replies.find(reply => reply.text.includes(marker) && reply.turn_id), 120000, 100) + state(s).session = { old, fixture, clear: event, newSession } + return s.result(s, 'new_conversation_identity', 'harness_transcript', { old_session: old.meta.parent_session, related_session: newSession, related_turn_id: reply.turn_id, new_transcript: file, official_event_artifact: fixture.adapter.hookFile, mechanism: 'own CLI終了→公式CLI新規起動' }) + } const command = s.harness === 'claude' ? '/clear' : s.harness === 'codex' ? '/new' : fixture.adapter.clear if (!command) sourceUnconfirmed(s.harness, '公式新会話command') await fixture.submit(command) @@ -304,7 +330,7 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { for (;;) { if (monitor.canceled) return const ready = target.spool.read().records.find((record, index) => index >= monitor.before && record.state === 'ready' && record.event.seq > 0) - if (ready) { monitor.suspended = { record: ready, operation: await pauseOwnedReceiver(owner, { parent: target.meta.parent_process, sameProcess: s.sameProcess, processDescendsFrom: s.processDescendsFrom, paused }) }; return } + if (ready) { monitor.suspended = { record: ready, operation: await pauseOwnedReceiver(owner, { target, fixture, sameProcess: s.sameProcess, processDescendsFrom: s.processDescendsFrom, paused }) }; return } await new Promise(resolve => setTimeout(resolve, 5)) } })().catch(error => { monitor.error = error }) @@ -312,7 +338,7 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { if (!owner || !s.sameProcess(owner)) fail('ACCEPTANCE_RECEIVER_NOT_LIVE', '専用watcherが存命ではありません') return s.result(s, 'own_receiver_suspension_armed', 'os_process', { owner, observation: 'ready保存の実境界を観測するmonitorの準備', monitor_started_at: new Date().toISOString() }) } - const operation = await pauseOwnedReceiver(owner, { parent: target.meta.parent_process, sameProcess: s.sameProcess, processDescendsFrom: s.processDescendsFrom, paused }) + const operation = await pauseOwnedReceiver(owner, { target, fixture, sameProcess: s.sameProcess, processDescendsFrom: s.processDescendsFrom, paused }) state(s).recovery = { fixture, target, owner, operation } return s.result(s, 'own_receiver_suspended', 'os_process', operation) } @@ -323,7 +349,7 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { const before = s.recordFor(post) if (before?.state !== 'ready' || before.event.body !== post.body || before.claim) fail('ACCEPTANCE_READY_NOT_RETAINED', 'claim前のready原文を確認できません') const old = recovery.owner - await pauseOwnedReceiver(old, { parent: recovery.target.meta.parent_process, sameProcess: s.sameProcess, processDescendsFrom: s.processDescendsFrom, paused, resume: true }) + await pauseOwnedReceiver(old, { target: recovery.target, fixture: recovery.fixture, sameProcess: s.sameProcess, processDescendsFrom: s.processDescendsFrom, paused, resume: true }) process.kill(old.pid, 'SIGKILL'); await s.until('停止した受信process消失', () => !s.sameProcess(old), 10000, 50) const target = await join(s) if (target.spool.id !== recovery.target.spool.id || s.recordFor(post).event.body !== post.body) fail('ACCEPTANCE_READY_RESTART_BINDING', 'readyとendpointを継承しませんでした') @@ -490,7 +516,8 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { if (!fixture.adapter.reconnectMcp) sourceUnconfirmed(s.harness, '通常CLIの公式MCP再接続command', { parent_session: target.meta.parent_session }) const before = target.spool.read(), tarball = mode === 'same' ? lifecycle.currentTarball : lifecycle.nextTarball const argv = ['install', '--prefix', lifecycle.prefix, '--ignore-scripts', '--no-audit', '--no-fund', tarball] - const output = execFileSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', argv, { encoding: 'utf8', shell: process.platform === 'win32' }) + const invocation = nativeInvocation(process.platform === 'win32' ? 'npm.cmd' : 'npm', argv, { shellCommand: s.shellCommand }) + const output = execFileSync(invocation.executable, invocation.argv, { encoding: 'utf8' }) const installed = JSON.parse(readFileSync(join(s.pkg, 'package.json'), 'utf8')) if (mode === 'same' && installed.version !== s.meta.package_version || mode === 'new' && installed.version === s.meta.package_version) fail('ACCEPTANCE_LIFECYCLE_VERSION', '要求した同版/新版の導入が不一致です') const after = target.spool.read() @@ -508,7 +535,8 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { const { endpointsFor } = await import(pathToFileURL(join(s.pkg, 'skill/scripts/parent-caller.mjs')).href) if (!endpointsFor().some(endpoint => endpoint.project !== fixture.project && endpoint.read().harness === s.harness && endpoint.read().runtime !== 'stopped')) fail('ACCEPTANCE_TEARDOWN_SHARED_CONNECTION_SCOPE', '通常global接続を保持する別fixtureが必要です') const bin = join(lifecycle.prefix, 'node_modules/.bin', process.platform === 'win32' ? 'peertable.cmd' : 'peertable') - const output = execFileSync(bin, ['teardown', fixture.project], { cwd: fixture.project, env: { ...process.env, PEERTABLE_TOKEN_SOURCE_FILE: lifecycle.tokenFile }, encoding: 'utf8', shell: process.platform === 'win32' }) + const invocation = nativeInvocation(bin, ['teardown', fixture.project], { shellCommand: s.shellCommand }) + const output = execFileSync(invocation.executable, invocation.argv, { cwd: fixture.project, env: { ...process.env, PEERTABLE_TOKEN_SOURCE_FILE: lifecycle.tokenFile }, encoding: 'utf8' }) const messages = (await target.api('messages')).messages if (!s.sameProcess(target.meta.parent_process) || original.some(before => !messages.some(after => isDeepStrictEqual(before, after)))) fail('ACCEPTANCE_TEARDOWN_PARENT_OR_HISTORY', 'teardownで親またはroom履歴を失いました') fixture.configuration.assertHooksUnchanged() @@ -532,7 +560,7 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { if (current?.work) { writeFileSync(current.work.release, '{}'); if (current.work.owner) await waitOwnedFixtureExit({ readEndpoints: () => [], knownOwners: [current.work.owner], indexExists: () => false, sameProcess: s.sameProcess, timeout: 30000 }) } if (current?.final) { writeFileSync(current.final.release, '{}'); if (current.final.monitor) { current.final.monitor.canceled = true; await current.final.monitor.promise } } if (current?.recovery?.monitor) { current.recovery.monitor.canceled = true; await current.recovery.monitor.promise } - for (const owner of paused.values()) if (s.sameProcess(owner)) await pauseOwnedReceiver(owner, { parent: self(s).meta.parent_process, sameProcess: s.sameProcess, processDescendsFrom: s.processDescendsFrom, paused, resume: true }) + for (const held of paused.values()) if (s.sameProcess(held.owner)) await pauseOwnedReceiver(held.owner, { target: held.target, fixture: held.fixture, sameProcess: s.sameProcess, processDescendsFrom: s.processDescendsFrom, paused, resume: true }) // queue撤去はこのfixtureが公式APIで作成したIDだけ。利用者/Aiterm実設定を削除しない。 for (const entry of current?.foreign?.queue ?? []) await current.foreign.fixture.rpc('thread/queue/delete', { threadId: self(s).meta.parent_session, queuedSubmissionId: entry.id }) if (current?.lease?.monitor) { @@ -557,7 +585,8 @@ export async function createNativeScenarioContext(name, options) { let primary try { primary = await factory.open({ harness: options.sourceMeta.harness, name: `scenario-${name}-${randomUUID().slice(0, 8)}`, prepare: async fixture => { - if (['final_race', 'session_change', 'foreign_ownership'].includes(name)) await fixture.addObserver({ events: name === 'session_change' ? ['SessionStart'] : ['Stop', 'PostToolUse'] }) + if (['final_race', 'session_change', 'foreign_ownership'].includes(name) && !(name === 'session_change' && ['cursor', 'grok'].includes(fixture.harness))) await fixture.addObserver({ events: fixture.harness === 'cursor' ? ['stop', 'postToolUse'] : name === 'session_change' ? ['SessionStart'] : ['Stop', 'PostToolUse'] }) + if (fixture.harness === 'cursor' && ['idle', 'consecutive', 'no_external_tools'].includes(name)) await fixture.addObserver({ events: ['stop'] }) if (['claim_race', 'slot_race'].includes(name)) await fixture.addProductCompetitors({ count: 2 }) if (name === 'compatibility_hooks') await fixture.addProductCompetitors({ count: 1, compatibilityHarness: options.sourceMeta.harness === 'claude' ? 'codex' : 'claude' }) } }) diff --git a/experiments/parent-product-acceptance/scenarios-native.test.mjs b/experiments/parent-product-acceptance/scenarios-native.test.mjs index 23a299ee..321bc343 100644 --- a/experiments/parent-product-acceptance/scenarios-native.test.mjs +++ b/experiments/parent-product-acceptance/scenarios-native.test.mjs @@ -6,7 +6,7 @@ import { createInterface } from 'node:readline' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { competitorProof, queueConnectionProof, monitorQueueConnections, createNativeActions, productToolError } from './scenarios-native.mjs' +import { competitorProof, queueConnectionProof, monitorQueueConnections, createNativeActions, productToolError, assertOwnedReceiver } from './scenarios-native.mjs' import { processIdentity, sameProcess } from '../../skill/scripts/parent-platform.mjs' import { scenarioPlan } from './scenarios.mjs' @@ -75,3 +75,19 @@ test('join失敗は公式parent-errorだけから判定し、既存endpointや assert.equal(productToolError({ ...error, state: 'verified' }, error.error_code), null) assert.equal(productToolError({ ...error, error_code: '別code' }, error.error_code), null) }) + + +test('独立watcherはown spoolとcaller本人で確認し、他endpoint共有とidentity差を拒否する', () => { + const parent = { pid: 100, started: '親開始' }, owner = { pid: 200, started: 'watcher開始' } + const fixture = { project: '/専用project', harness: 'codex' } + const state = { endpoint_id: 'own', harness: 'codex', runtime: 'armed', caller: { harness: 'codex', conversation: 'own会話', owner: parent }, watcher: owner } + const target = { fixture, meta: { parent_process: parent, parent_session: 'own会話' }, spool: { project: fixture.project, id: 'own', read: () => state } } + const options = { target, fixture, sameProcess: () => true, processDescendsFrom: () => false, endpoints: [] } + assert.equal(assertOwnedReceiver(owner, options).receiver_role, 'watcher') + assert.throws(() => assertOwnedReceiver({ ...owner, started: '再利用PID' }, options), { code: 'ACCEPTANCE_PROCESS_FAULT_OWNER' }) + assert.throws(() => assertOwnedReceiver(owner, { ...options, endpoints: [{ id: '他endpoint', read: () => ({ runtime: 'armed', watcher: owner }) }] }), { code: 'ACCEPTANCE_PROCESS_FAULT_SHARED' }) + state.caller.conversation = '別会話' + assert.throws(() => assertOwnedReceiver(owner, options), { code: 'ACCEPTANCE_PROCESS_FAULT_CALLER' }) + state.caller.conversation = 'own会話'; state.watcher = null; state.waiter = { owner } + assert.throws(() => assertOwnedReceiver(owner, options), { code: 'ACCEPTANCE_PROCESS_FAULT_OWNER' }) +}) diff --git a/experiments/parent-product-acceptance/scenarios.test.mjs b/experiments/parent-product-acceptance/scenarios.test.mjs index c430c2e6..f68be3ea 100644 --- a/experiments/parent-product-acceptance/scenarios.test.mjs +++ b/experiments/parent-product-acceptance/scenarios.test.mjs @@ -7,7 +7,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { scenarios as inventory } from '../../scripts/parent-delivery-acceptance.mjs' import { scenarioNames, scenarioPlan, runScenario, validateBoundary, validateNativeCheck } from './scenarios.mjs' -import { createScenarioProxy, nativeReadPages, assembleNativePages, noToolsReception, codexTurns, assertCodexBusy, nativeReceiveToolAllowed, nativeReceiveReaderAllowed } from './scenarios-context.mjs' +import { createScenarioProxy, nativeReadPages, assembleNativePages, noToolsReception, codexTurns, assertCodexBusy, nativeReceiveToolAllowed, nativeReceiveReaderAllowed, cursorIdleCompletion } from './scenarios-context.mjs' const check = () => ({ run_id: 'run', scenario: 'package', status: 'passed', body_equal: true, count: 1, nonce: 'unique', harness: 'claude', original: { room: 'room', from: 'probe', to: 'bell', seq: 1, body: '日本語 & 字面>' }, received: { room: 'room', from: 'probe', to: 'bell', seq: 1, body: '日本語 & 字面>' }, delivery: { session: 'session', turn_id: 'turn', order: 2 }, reply: { session: 'session', turn_id: 'turn2', order: 3, text: 'unique' }, receipt: { result: 'delivered', receipt_revision: 1 } }) const checkScope = { runId: 'run', scenario: 'package', session: 'session' } @@ -153,3 +153,13 @@ test('全量readerは製品の実taskと公式Read/get出力の相関がある assert.equal(nativeReceiveReaderAllowed(get, [registration], [task], 's'), true) assert.equal(nativeReceiveReaderAllowed({ ...get, input: { ...get.input, timeout_ms: 1 } }, [registration], [task], 's'), false) }) + + +test('Cursorのidleは対象会話/generationの公式completed stopだけを採用する', () => { + const last = { session: 'own', turn_id: '最新generation' } + const entry = { pid: 123, event: { hook_event_name: 'stop', status: 'completed', conversation_id: 'own', generation_id: last.turn_id } } + assert.equal(cursorIdleCompletion([entry], last, 'own'), entry) + for (const changed of [{ generation_id: '過去generation' }, { conversation_id: '別会話' }, { status: 'aborted' }, { hook_event_name: 'postToolUse' }]) assert.equal(cursorIdleCompletion([{ ...entry, event: { ...entry.event, ...changed } }], last, 'own'), null) + assert.equal(cursorIdleCompletion([{ event: {} }], last, 'own'), null) + assert.throws(() => cursorIdleCompletion([entry], { ...last, session: '別会話' }, 'own'), { code: 'ACCEPTANCE_IDLE_NATIVE_END_MISSING' }) +}) From 017bf2c6342f573b23fa8b00c19ae34d0d291392 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:36:17 +0900 Subject: [PATCH 34/72] =?UTF-8?q?=E8=A6=AA=E3=81=AE=E7=B5=82=E4=BA=86?= =?UTF-8?q?=E5=87=A6=E7=90=86=E3=81=A8=E8=A8=AD=E5=AE=9A=E8=A7=A3=E9=99=A4?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E7=90=86=E3=81=97=E5=86=8D=E6=B8=AC=E5=AE=9A?= =?UTF-8?q?=E3=81=99=E3=82=8B=E9=85=8D=E5=B8=83source=E3=82=92=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/current-design.md | 4 +- docs/plan_parent-native-delivery.md | 10 +- experiments/codex-config-removal-repro.mjs | 28 ++ .../parent-product-acceptance/README.md | 14 +- .../parent-product-acceptance/evidence.mjs | 2 + .../evidence.test.mjs | 9 +- .../parent-product-acceptance/harness.mjs | 17 +- experiments/parent-product-acceptance/run.mjs | 64 +++-- .../runner-provenance.mjs | 21 ++ .../runner-provenance.test.mjs | 23 ++ .../scenarios-lease-run.mjs | 19 +- rag/INDEX.md | 3 +- rag/parent-delivery/cli-lifecycle-repairs.md | 33 +++ rag/parent-delivery/product-acceptance.json | 256 +---------------- rag/parent-delivery/receivers.md | 4 +- .../live/darwin/cursor/cli/audience.json | 0 .../live/darwin/grok/cli/audience.json | 0 .../live/linux/cursor/cli/audience.json | 0 .../live/linux/grok/cli/audience.json | 0 .../live/win32/cursor/cli/audience.json | 0 .../live/win32/grok/cli/audience.json | 0 .../parent-delivery/product-acceptance.json | 258 ++++++++++++++++++ scripts/ci-contract.test.mjs | 2 +- scripts/run-product-ci.mjs | 2 +- skill/scripts/parent-caller.mjs | 6 +- skill/scripts/parent-connect.mjs | 59 ++-- skill/scripts/parent-delivery.test.mjs | 36 ++- ...rent-platform-unlinked-executable.test.mjs | 38 +++ skill/scripts/parent-platform.mjs | 10 +- skill/scripts/parent-process.mjs | 3 +- skill/scripts/parent-receivers/codex.mjs | 5 +- 31 files changed, 585 insertions(+), 341 deletions(-) create mode 100644 experiments/codex-config-removal-repro.mjs create mode 100644 experiments/parent-product-acceptance/runner-provenance.mjs create mode 100644 experiments/parent-product-acceptance/runner-provenance.test.mjs create mode 100644 rag/parent-delivery/cli-lifecycle-repairs.md rename rag/parent-delivery/{ => snapshots/43348b8/rag/parent-delivery}/live/darwin/cursor/cli/audience.json (100%) rename rag/parent-delivery/{ => snapshots/43348b8/rag/parent-delivery}/live/darwin/grok/cli/audience.json (100%) rename rag/parent-delivery/{ => snapshots/43348b8/rag/parent-delivery}/live/linux/cursor/cli/audience.json (100%) rename rag/parent-delivery/{ => snapshots/43348b8/rag/parent-delivery}/live/linux/grok/cli/audience.json (100%) rename rag/parent-delivery/{ => snapshots/43348b8/rag/parent-delivery}/live/win32/cursor/cli/audience.json (100%) rename rag/parent-delivery/{ => snapshots/43348b8/rag/parent-delivery}/live/win32/grok/cli/audience.json (100%) create mode 100644 rag/parent-delivery/snapshots/43348b8/rag/parent-delivery/product-acceptance.json create mode 100644 skill/scripts/parent-platform-unlinked-executable.test.mjs diff --git a/docs/current-design.md b/docs/current-design.md index 5383e56d..2efa4deb 100644 --- a/docs/current-design.md +++ b/docs/current-design.md @@ -74,11 +74,11 @@ Peertableは原文spoolと受信cursor、Claudeの公式asyncRewake、Codexの setup、resume、teardownはそれぞれ一回の製品入口で必要な順序を完結させる。利用AIへbridge停止、更新、再起動、ready確認の順序選択を委ねない。 - global npm installは検出したAIのスキル配置を更新する。再実行入口は`peertable install`。所有するPeertableリンクだけを管理し、共有AI設定本文や他製品の設定を変更しない。installからprojectの再構築を行わない。 -- 親の接続は`peertable connect --target claude|codex|grok|cursor`が所有する。Peertable専用MCP・公式hookだけを管理し、他製品の設定・承認・順序を保持する。設定追加だけで実会話への受信成立としない。 +- 親の接続は`peertable connect --target claude|codex|grok|cursor`が所有する。Peertable専用MCP・公式hookだけを管理し、他製品の設定・承認・順序を保持する。設定追加だけで実会話への受信成立としない。導入前のJSONの構造を所有記録へ残し、解除時は導入した空event・容器だけを撤去して利用者の現在値を保つ。CodexのTOML解除には公式config APIを使う。 - setupは明示projectの生成物、接続、bridge起動、ready確認までを持つ。再実行時は既存roomと議題を保ってresumeへ進む。席の追加は`peertable launch`がroom登録と実ターン開始まで確認する。 - 着任指示の成立まで、その席への通常通知はpendingに保持する。初回指示と参加通知の配達順序はPeertableが所有し、起動中断の記録は再着席前の退席処理で解除する。 - resumeはPeertable所有の生成物とroom MCPを現行treeへ同期してから席を復帰する。 -- 親の実processが終了した場合も、watcherはendpointを停止して所有するglobal索引を撤去する。配送履歴・保存本文はprojectに残し、他の親の索引は保持する。故障した受信口はresumeの対象として索引を保持する。 +- 親の実processが終了した場合も、watcherはendpointを停止して所有するglobal索引を撤去する。Windowsでは同じwatcherをWMIの起動APIで親のMCP Jobの外へ起動し、親終了後の自己停止を維持する。配送履歴・保存本文はprojectに残し、他の親の索引は保持する。故障した受信口はresumeの対象として索引を保持する。 - teardownは親の購読・背景受信とwakeup、seat-status、alarmの3 bridgeを停止してから生成物を片付ける。親のharness processは終了せず、親の配送履歴と受付不明の本文を保持する。既定は席と足場だけを畳み、room履歴とLattice storeを残す。痕跡ゼロは明示した`--purge`だけで行う。 roomは解散状態(archive)を持つ。teardownがroomをarchiveし、公開一覧(`/api/rooms`のrooms欄とトップページ主一覧)から外す。個別ページとログAPIは読めるまま残る。次のsetupのmember登録が同じroomを自動で現役へ戻す。公開面に並ぶのは現役の卓だけである。 diff --git a/docs/plan_parent-native-delivery.md b/docs/plan_parent-native-delivery.md index 899d94c1..c8f8e6e4 100644 --- a/docs/plan_parent-native-delivery.md +++ b/docs/plan_parent-native-delivery.md @@ -94,7 +94,7 @@ MCP requestと公式hookから実際の親を識別する。AIにthread IDやcon `parent_join`のreceiptは`schema: peertable.parent-join-result.v1`、`endpoint_id`、`state: binding_pending | receiving | verified | failed`、`harness`、`wait_process`、`error_code`を持つ。Cursor/Grokの結果返却後にhookで束縛する場合、最初のreceiptは`binding_pending`であり、成功やreadyと書かない。hookが返却結果の`endpoint_id`を実会話へ束縛した時点で`receiving`へ進み、購読とprobe配送を開始する。probeがその会話の受信経路を通った後に`verified`へ進める。`verified`を配送開始の条件にしてprobeを止める循環を作らない。進行は製品が続け、オーナーに手順の再実行を求めない。 -事前hookで実会話を照合できたjoinは、返却時点で`receiving`にできる。事後hookで初めて束縛するjoinだけが`binding_pending`を返す。旧watcherからの移行中は、束縛とreceiver準備が済んでも旧本文出力の停止が確認されるまで新watchとprobeを開始しない。束縛待ち・probe待ちはそれぞれ30秒を初期の明示期限とし、前者は`PARENT_BIND_TIMEOUT`、後者は`PARENT_PROBE_TIMEOUT`で失敗を残す。probe期限はnative receiverの準備確認後から計る。背景toolの登録前は、必要な登録入力を伴う`rearm_pending`として表示する。Windows Grokの実測ではモデルが登録toolを呼ぶまで数分かかり、join時点から計る旧実装の期限とは目的が異なった。遅れて確認された現在のprobeは、そのprobeの期限エラーだけを解除する。実機の開始時間を第1工程で測り、必要なら根拠を残して期限を調整する。無期限のpendingにしない。 +現行のClaude/Cursor/Grokは事前hookで実会話と入力digestを照合し、照合記録が30秒を超えた場合はendpoint作成前に`PARENT_BIND_TIMEOUT`で失敗する。Codexは公式MCPのthread metadataと実processを直接照合する。事前hookで実会話を照合できたjoinは、返却時点で`receiving`にできる。事後hookで初めて束縛するjoinだけが`binding_pending`を返す。旧watcherからの移行中は、束縛とreceiver準備が済んでも旧本文出力の停止が確認されるまで新watchとprobeを開始しない。束縛待ち・probe待ちはそれぞれ30秒を初期の明示期限とし、前者は`PARENT_BIND_TIMEOUT`、後者は`PARENT_PROBE_TIMEOUT`で失敗を残す。probe期限はnative receiverの準備確認後から計る。背景toolの登録前は、必要な登録入力を伴う`rearm_pending`として表示する。Windows Grokの実測ではモデルが登録toolを呼ぶまで数分かかり、join時点から計る旧実装の期限とは目的が異なった。遅れて確認された現在のprobeは、そのprobeの期限エラーだけを解除する。実機の開始時間を第1工程で測り、必要なら根拠を残して期限を調整する。無期限のpendingにしない。 登録の疎通実績と現在の受信継続は別に記録する。runtimeの受信状態は`armed | rearm_pending | stopped | failed`とし、背景taskが終わって次の待機が登録されるまでを`rearm_pending`として診断へ出す。Cursor/Grokではnative背景toolの呼出しと結果を公式hookで相関し、task ID・受信process identity・endpointを保存する。receiptを生成しただけでは`armed`にしない。再登録までに届いた本文は配送記録へ保持し、次の待機が拾う。永久に再登録されない状態を健康と表示しない。 @@ -378,8 +378,10 @@ Codexの公式hook本文はXMLのtextとして記録される。[一次仕様の 修理後のmacOS通常CLIで、Claude/CodexのDM・複数宛・ALLについて親の実会話・応答・原文・receiptを再確認し、親終了後の自動停止と共有索引の撤去、他設定の保持を確認した。[修理後の候補観測](../rag/parent-delivery/repaired-cli-candidate-observation.json)は実測commitをそのまま記録し、最終manifestへ流用しない。 -最終受入の製品sourceは`43348b8c3557e8abe43e0ee45d3c9b8b19e427f8`へ揃えて測定する。試験制御用の`experiments/`は配布物に含めず、実測した製品sourceとは別にrunnerのcommitを記録する。異なるcommitで得た証拠の`source_commit`を書き換えず、最終対象で実行する。実機で確認した起動dialogと終了確認のrunner修理は統合済みで、関連focused testは9件合格した。 +旧候補`43348b8`の実機試験で、Linuxの実行中binary更新と、WindowsのCodex終了時に配送watcherが消される問題を再現した。設定解除ではCodexのTOMLに空行が残り、ClaudeのJSONに導入時の空eventが残った。これらの修理により旧候補を最終sourceとして扱えなくなった。[修理の原因と検証](../rag/parent-delivery/cli-lifecycle-repairs.md)へ実測と未確認の境界を保存する。 -この固定sourceでCursor/Grok通常CLIの3 OS audienceを取得し、[親の独立監査](../rag/parent-delivery/cursor-grok-formal-review.json)で18件の原文・公式turn・同会話の後続返答を生証跡と照合して受け入れた。[最終manifest](../rag/parent-delivery/product-acceptance.json)へ実測のまま保存し、他scenarioやDesktopの成功へ広げない。Linux Grokの複数宛とWindows GrokではTUI画面の取得不足を残した。旧候補の証拠を書き換えて使っていない。 +旧候補で独立監査したCursor/Grokの6 CLI audience・18本文は、[試験当時のmanifest](../rag/parent-delivery/snapshots/43348b8/rag/parent-delivery/product-acceptance.json)と同じsnapshot内の証拠へ移して保存した。本文・source・判定は書き換えない。Linux Grokの複数宛とWindows Grokの画面取得不足も保持する。[現在の受入manifest](../rag/parent-delivery/product-acceptance.json)は修理後の再測定まで空とし、旧候補の成功で公開gateを通さない。 -24 scenarioの手順と実操作moduleをCLI runnerへ接続した。作業中・待機・連続配送・tool省略・原文全量・上限超過・source再接続・出力中断・receipt再送・配布物確認は既存contextで実行でき、残る13 scenarioのnative adapterは実装中である。必要adapterのない試験は操作前にtyped errorで止まり、成績を作らない。統合の関連focused testは25件合格した。製品sourceは固定したまま、試験controllerの各fileのSHA-256を証拠へ記録する。 +24 scenarioの手順と実操作moduleをCLI runnerへ接続した。既存contextの11 scenarioに加え、残る13 scenarioには新しい専用project・公式session・spoolを持つnative fixtureを接続する。各操作の実境界が未確認ならtyped errorで止まり、合格を作らない。Cursor/Grokのtask取消・idle・会話切替の観測は仕上げ中である。長時間leaseは通常の製品期限を実際に跨ぎ、期限短縮や時刻書換えで代用しない。修理後の最終sourceを固定してから正式試験を開始する。 + +試験controllerは製品sourceとは別のcommitと全実行moduleのSHA-256を記録し、実行fileを指定commitのGit blobへ照合する。Windowsの試験起動もPowerShell 7へ統一した。設定解除のJSON比較で空eventを除く補正を廃止し、Codexの信頼entryは専用project配下だけを解除する。自己停止は親終了からの実時間を記録し、30秒を超えた停止やrunnerによる止血を合格にしない。 diff --git a/experiments/codex-config-removal-repro.mjs b/experiments/codex-config-removal-repro.mjs new file mode 100644 index 00000000..fdca9b23 --- /dev/null +++ b/experiments/codex-config-removal-repro.mjs @@ -0,0 +1,28 @@ +// 公式Codexの設定APIで追加・解除するfocused再現。認証なしの専用設定dirで、親受信の実機成績は作らない。 +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { codexConnection } from '../skill/scripts/parent-receivers/codex.mjs' +import { removeCodexConfiguration } from '../skill/scripts/parent-connect.mjs' + +const dir = mkdtempSync(join(tmpdir(), 'peertable-codex-config-removal-')) +const file = join(dir, 'config.toml') +const before = '#利用者\n[features]\nhooks = true\n\n[hooks.state.\'foreign:key\']\nenabled = false\ntrusted_hash = "利用者の承認"\n' +writeFileSync(file, before) +let client +try { + client = await codexConnection(dir, { codexHome: dir }) + const ownKey = 'own:key.with.dot"and-quote' + await client.request('config/batchWrite', { filePath: file, edits: [ + { keyPath: 'mcp_servers.peertable_parent', value: { command: process.execPath, args: ['product-entry.mjs'] }, mergeStrategy: 'replace' }, + { keyPath: `hooks.state.${JSON.stringify(ownKey)}`, value: { enabled: true, trusted_hash: '自分の承認' }, mergeStrategy: 'replace' }, + ] }) + assert.notEqual(readFileSync(file, 'utf8'), before) + await removeCodexConfiguration(client, file, [{ key: ownKey }]) + assert.equal(readFileSync(file, 'utf8'), before) + console.log(JSON.stringify({ schema: 'peertable.codex-config-removal-repro.v1', status: 'passed', kind: 'offline_official_config_api_not_product_live', exact_foreign_text: true })) +} finally { + if (client) await client.close() + rmSync(dir, { recursive: true, force: true }) +} diff --git a/experiments/parent-product-acceptance/README.md b/experiments/parent-product-acceptance/README.md index 92556b27..ae6af041 100644 --- a/experiments/parent-product-acceptance/README.md +++ b/experiments/parent-product-acceptance/README.md @@ -1,12 +1,12 @@ # 親配送の正式受入runner -`scripts/parent-delivery-acceptance.mjs`が要求する`peertable.parent-live-case.v1`の証拠を、通常CLIの実親から取得する。対象はClaude Code/Codexの対話TUIだけで、Desktop/IDE、Cursor、Grokは扱わない。fixture、stream-json、app-serverの結果は証拠にしない。 +`scripts/parent-delivery-acceptance.mjs`が要求する`peertable.parent-live-case.v1`の証拠を、通常CLIの実親から取得する。対象はClaude Code/Codexの対話TUIだけで、Desktop/IDE、Cursor、Grokは扱わない。fixture・stream-json・診断app-serverだけの結果を、実親への配送合格にしない。公式queueの受付証拠は実会話への配送・応答と合わせて照合する。 ## 実行 ```sh node experiments/parent-product-acceptance/run.mjs --harness claude|codex \ - --source <検証対象commit 40桁> --digest --version \ + --source <検証対象commit 40桁> --digest --version --runner-commit \ [--scenarios audience,idle,original,...] [--out ] [--model ] ``` @@ -17,8 +17,8 @@ runnerは1回の呼出しで次を連続して行う。 3. 対象harnessのglobal設定をtarで退避し、導入物の`peertable connect --target `でPeertable所有entryだけを登録する。 4. 配布物の`room/server.mjs`で試験専用roomを起動する。tokenは`PEERTABLE_TOKEN_SOURCE_FILE`経由で標準のseat-credential経路へ渡す。 5. Aitermの公開MCP(`aiterm-mcp`)でPTYを開き、通常HOME・通常認証のまま`claude`/`codex`を起動する。Aitermの結果は宣言済みschemaのstructuredContentだけを読み、人間向けtextは解釈しない。`pty_open`は出力schemaを持たないため、指定したnameのsessionを`pty_observe`のstructured結果(`session_id`一致・`exists:true`)で確かめ、確かめられなければ`ACCEPTANCE_AITERM_SESSION_ID_MISSING`で止まる。試験dirの信頼dialogだけを肯定し、未知のdialogでは止まる。 -6. 実親に`parent_join`を1回呼ばせ、`verified`を待ってからscenarioを実測する。製品sourceのcommitと、実行controller各fileのSHA-256は別々に記録する。 -7. 後片付けを逆順で行う。harness終了、製品自身のendpoint停止の観測(30秒)、Codexの試験dir信頼entry削除、PTY close、room停止、`connect --remove`、設定の意味比較。各段の結果は`cleanupFailure`が完了条件と照合する。harness未終了、所有processの残存、endpoint未停止、pane・room processの残存、信頼entryの残存、connect解除の失敗、意味比較の不一致、Codex `config.toml`本文の不一致は、それぞれ原因code付きの`failed`になり、runは失敗で終わる。退避tarは`connect_remove`の完了条件をすべて満たした時だけ消す。製品が親終了後30秒以内にendpointを止めなかった場合(runnerが止める)と、停止済みendpointの索引が残った場合(runnerが自分の試験entryだけを外す)は、後片付け自体は続けるが、`findings`へ記録し、`endpoint_stop`を`PRODUCT_ENDPOINT_NOT_SELF_STOPPED`・`PRODUCT_ENDPOINT_STOPPED_BY_RUNNER`・`PRODUCT_STOPPED_ENDPOINT_INDEX_LEFT`の`failed`にしてrunを失敗で終える。 +6. 実親に`parent_join`を1回呼ばせ、`verified`を待ってからscenarioを実測する。製品sourceのcommitと、実行controllerのcommit・各fileのSHA-256は別々に記録し、指定commitのGit blobと実行fileを照合する。 +7. 後片付けを逆順で行う。harness終了、製品自身のendpoint停止の観測(合格期限30秒、遅延測定は最大120秒)、Codexの試験dir信頼entry削除、PTY close、room停止、`connect --remove`、設定の意味比較。各段の結果は`cleanupFailure`が完了条件と照合する。harness未終了、所有processの残存、endpoint未停止、pane・room processの残存、信頼entryの残存、connect解除の失敗、意味比較の不一致、Codex `config.toml`本文の不一致は、それぞれ原因code付きの`failed`になり、runは失敗で終わる。退避tarは`connect_remove`の完了条件をすべて満たした時だけ消す。製品が親終了後30秒以内にendpointを止めなかった場合(runnerが止める)と、停止済みendpointの索引が残った場合(runnerが自分の試験entryだけを外す)は、後片付け自体は続けるが、`findings`へ記録し、`endpoint_stop`を`PRODUCT_ENDPOINT_NOT_SELF_STOPPED`・`PRODUCT_ENDPOINT_STOPPED_BY_RUNNER`・`PRODUCT_STOPPED_ENDPOINT_INDEX_LEFT`の`failed`にしてrunを失敗で終える。 Codexだけ、session層の`-c`を2つ足す。MCPへtoken参照先を渡す`mcp_servers.peertable_parent.env_vars`と、無人実行で更新dialogを出さない`check_for_update_on_startup=false`である。Codexのtrust dialogは`-c`では回避できないため、起動時に受諾する。書かれた`projects.<試験dir>`は、終了後に公式`config/batchWrite`で削除する。 @@ -48,10 +48,12 @@ Codexは`Stop`/`PostToolUse` hookの出力を``要素へ入れ、 ## 実装済みscenario -`audience`はDM・親を含む複数宛・allを各1回送り、room/from/to/seq/本文一致と同会話の後続返答を測る。他の24 scenarioは`scenarios.mjs`に正本の全手順を持ち、`run.mjs`から`createScenarioContext`/`runScenario`を呼ぶ。contextが実装した操作だけを実行し、必要なnative adapterが揃わないscenarioは障害操作の前に`ACCEPTANCE_SCENARIO_ADAPTER_MISSING`で止まる。手順の存在と実機の合格は別に扱う。 +`audience`はDM・親を含む複数宛・allを各1回送り、room/from/to/seq/本文一致と同会話の後続返答を測る。他の24 scenarioは`scenarios.mjs`に正本の全手順を持ち、`run.mjs`から`createScenarioContext`/`runScenario`を呼ぶ。contextに操作がなければ`ACCEPTANCE_SCENARIO_ADAPTER_MISSING`で止まり、実装した操作でも公式境界が未確認なら原因code付きの失敗で止まる。手順の存在と実機の合格は別に扱う。 -既存contextで実行できるのは`busy`、`idle`、`consecutive`、`no_external_tools`、`no_tools`、`original`、`burst`、`source_reconnect`、`output_interruption`、`receipt_retry`、`package`。残る13 scenarioのnative adapterは実装中である。各scenarioは専用nonce、実会話・turn・後続返答、原文一致、receipt、境界artifactを照合し、観測が欠ければ成績を作らない。 +既存contextで実行できるのは`busy`、`idle`、`consecutive`、`no_external_tools`、`no_tools`、`original`、`burst`、`source_reconnect`、`output_interruption`、`receipt_retry`、`package`。残る13 scenarioは`createNativeScenarioContext`で専用project・新しい公式sessionを作る。native操作は実装・実境界の確認を進めており、未確認の操作はtyped errorで止まる。各scenarioは専用nonce、実会話・turn・後続返答、原文一致、receipt、境界artifactを照合し、観測が欠ければ成績を作らない。 `source_reconnect`と`receipt_retry`では、試験親のHTTP/SSE接続だけを専用proxyへ向ける。runnerの投稿・観測APIはroomへ直接接続する。後片付けではproxyの接続を切ってlistenの終了を確認し、未終了なら`ACCEPTANCE_PROXY_NOT_CLOSED`でrunを失敗にする。 focused test: `node --test experiments/parent-product-acceptance/evidence.test.mjs experiments/parent-product-acceptance/scenarios.test.mjs` + +lease単独runは`scenarios-lease-run.mjs`へ導入物・tarball SHA・製品source/digest/version・`--runner-commit`を渡す。短時間runとroom/processを分離し、実際の製品期限まで待つ。通常HOMEと公式認証を維持し、global設定や認証を長時間fixtureへ複製しない。実行moduleに未commit差分があればprovenance照合で止まる。 diff --git a/experiments/parent-product-acceptance/evidence.mjs b/experiments/parent-product-acceptance/evidence.mjs index c23081ab..24e58aec 100644 --- a/experiments/parent-product-acceptance/evidence.mjs +++ b/experiments/parent-product-acceptance/evidence.mjs @@ -77,6 +77,8 @@ export function buildCase({ meta, scenario, checks, observations, extra = {} }) parent_session: meta.parent_session, parent_process: meta.parent_process, endpoint_id: meta.endpoint_id, room: meta.room, owner_input_required: false, isolation: meta.isolation, ...(meta.controller ? { controller: meta.controller } : {}), + ...(meta.runner ? { runner: meta.runner } : {}), + ...(meta.runner_commit ? { runner_commit: meta.runner_commit, runner_files: meta.runner_files } : {}), observations, receipts: checks.map(check => check.receipt).filter(Boolean), body_checks: checks.filter(check => check.received).map(check => ({ label: check.label, original: check.original, received: check.received })), checks, ...extra, diff --git a/experiments/parent-product-acceptance/evidence.test.mjs b/experiments/parent-product-acceptance/evidence.test.mjs index 84de0735..997b667f 100644 --- a/experiments/parent-product-acceptance/evidence.test.mjs +++ b/experiments/parent-product-acceptance/evidence.test.mjs @@ -4,7 +4,7 @@ import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { parseDelivered, judgeAudience, buildCase, buildRecord, selfAudit, acceptCase, cleanupFailure } from './evidence.mjs' -import { readJsonl, readTranscript, startupAction } from './harness.mjs' +import { readJsonl, readTranscript, startupAction, ownTrustKeys } from './harness.mjs' import { openAiterm } from './aiterm.mjs' import { renderDelivery } from '../../skill/scripts/parent-delivery.mjs' @@ -147,3 +147,10 @@ test('起動dialogは観測済みの文言だけに操作を返す', () => { assert.deepEqual(startupAction('codex', ' ✨ Update available! 0.155.1 -> 0.158.0\n'), { blocked: 'codex_update_prompt' }) assert.equal(startupAction('codex', ' Some new dialog\n› 1. Accept\n'), null) }) + +test('Codexの信頼entryはWindowsの大小文字差を吸収し同prefixの隣projectを残す', () => { + const out = 'C:\\Users\\kite_\\ptacc\\run' + const projects = { 'c:\\users\\kite_\\ptacc\\run\\project-1': {}, 'C:\\Users\\kite_\\ptacc\\run-other\\user': {}, 'c:\\users\\kite_\\ptacc\\run\\old': {} } + assert.deepEqual(ownTrustKeys(projects, { 'C:\\Users\\kite_\\ptacc\\run\\old': {} }, out, 'win32'), ['c:\\users\\kite_\\ptacc\\run\\project-1']) + assert.deepEqual(ownTrustKeys({ '/tmp/Run/p': {}, '/tmp/run/p': {}, '/tmp/run-other/p': {} }, {}, '/tmp/run', 'linux'), ['/tmp/run/p']) +}) diff --git a/experiments/parent-product-acceptance/harness.mjs b/experiments/parent-product-acceptance/harness.mjs index 42fe9abc..7f6452a9 100644 --- a/experiments/parent-product-acceptance/harness.mjs +++ b/experiments/parent-product-acceptance/harness.mjs @@ -2,17 +2,14 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' -import { execFileSync } from 'node:child_process' +import { resolveOfficialCli } from './scenarios-surfaces.mjs' +import * as platform from '../../skill/scripts/parent-platform.mjs' const posixQuote = value => `'${String(value).replaceAll("'", `'\\''`)}'` const psQuote = value => `'${String(value).replaceAll("'", "''")}'` export function resolveCli(harness) { - const name = { claude: 'claude', codex: 'codex' }[harness] - if (!name) throw Object.assign(new Error(`未対応のharnessです: ${harness}`), { code: 'ACCEPTANCE_HARNESS_UNSUPPORTED' }) - const found = execFileSync(process.platform === 'win32' ? 'where.exe' : 'which', [name], { encoding: 'utf8' }).trim().split(/\r?\n/u)[0] - const version = execFileSync(found, ['--version'], { encoding: 'utf8', shell: process.platform === 'win32' }).trim().split(/\r?\n/u)[0] - return { executable: found, version } + return resolveOfficialCli(harness, platform) } // 通常起動のargvへ足すのは、試験roomのtoken参照先をMCPへ渡す指定と、無人実行でCLI更新を促さない指定だけ(session層)。 @@ -46,6 +43,14 @@ export function startupAction(harness, screen) { return null } +// Windowsのpath表記差を吸収し、隣の同prefixのprojectを自分の信頼entryに混ぜない。 +export function ownTrustKeys(projects, baselineProjects, out, platform = process.platform) { + const norm = value => platform === 'win32' ? value.replaceAll('/', '\\').toLowerCase() : value + const base = norm(out).replace(/[\\/]$/u, ''), separator = platform === 'win32' ? '\\' : '/' + const baseline = new Set(Object.keys(baselineProjects ?? {}).map(norm)) + return Object.keys(projects ?? {}).filter(key => (norm(key) === base || norm(key).startsWith(`${base}${separator}`)) && !baseline.has(norm(key))) +} + // 自sessionの記録fileだけを名前で特定する。他会話の中身は読まない。 export function transcriptPath(harness, session) { if (harness === 'claude') { diff --git a/experiments/parent-product-acceptance/run.mjs b/experiments/parent-product-acceptance/run.mjs index a7967090..f18df905 100644 --- a/experiments/parent-product-acceptance/run.mjs +++ b/experiments/parent-product-acceptance/run.mjs @@ -10,10 +10,14 @@ import { randomUUID } from 'node:crypto' import { fileURLToPath, pathToFileURL } from 'node:url' import { isDeepStrictEqual } from 'node:util' import { openAiterm } from './aiterm.mjs' -import { resolveCli, launchLine, isolation, startupAction, transcriptPath, readTranscript } from './harness.mjs' +import { resolveCli, launchLine, isolation, startupAction, transcriptPath, readTranscript, ownTrustKeys } from './harness.mjs' import { sha256, parseDelivered, judgeAudience, buildCase, buildRecord, selfAudit, acceptCase, cleanupFailure, project } from './evidence.mjs' import { scenarioNames, runScenario } from './scenarios.mjs' import { createScenarioContext, createScenarioProxy } from './scenarios-context.mjs' +import { createNativeScenarioContext } from './scenarios-native.mjs' +import { nativeInvocation } from './scenarios-surfaces.mjs' +import { runnerProvenance } from './runner-provenance.mjs' +import { shellCommand } from '../../skill/scripts/parent-platform.mjs' const repo = fileURLToPath(new URL('../../', import.meta.url)) const args = Object.fromEntries(process.argv.slice(2).reduce((pairs, value, index, all) => value.startsWith('--') ? [...pairs, [value.slice(2), all[index + 1]?.startsWith('--') ? true : all[index + 1] ?? true]] : pairs, [])) @@ -26,7 +30,7 @@ const known = ['audience', ...scenarioNames] for (const name of scenarios) if (!known.includes(name)) fail('ACCEPTANCE_SCENARIO_UNKNOWN', `正本にないscenarioです: ${name}`) if (new Set(scenarios).size !== scenarios.length) fail('ACCEPTANCE_SCENARIO_DUPLICATE', '同じscenarioを1回のrunで重複指定できません') if (!['claude', 'codex'].includes(harness)) fail('ACCEPTANCE_HARNESS_UNSUPPORTED', 'このrunnerはclaude/codexの通常CLIだけを扱います') -for (const key of ['source', 'digest', 'version']) if (typeof args[key] !== 'string') fail('ACCEPTANCE_ARGS', `--${key} が必要です`) +for (const key of ['source', 'digest', 'version', 'runner-commit']) if (typeof args[key] !== 'string') fail('ACCEPTANCE_ARGS', `--${key} が必要です`) const out = realpathSync(args.out ? (mkdirSync(args.out, { recursive: true }), args.out) : mkdtempSync(join(tmpdir(), `peertable-parent-acceptance-${harness}-`))) const privateDir = join(out, 'private'); mkdirSync(privateDir, { recursive: true, mode: 0o700 }); chmodSync(privateDir, 0o700) @@ -34,7 +38,10 @@ const publicDir = join(out, 'public'); mkdirSync(publicDir, { recursive: true }) const log = (kind, value = {}) => { const row = { at: new Date().toISOString(), kind, ...project(value) }; appendFileSync(join(out, 'run-log.jsonl'), JSON.stringify(row) + '\n'); console.log(JSON.stringify(row)) } const git = (...argv) => execFileSync('git', argv, { cwd: repo, encoding: 'utf8' }).trim() const until = async (label, probe, ms, every = 1000) => { const end = Date.now() + ms; for (;;) { const value = await probe(); if (value) return value; if (Date.now() > end) fail('ACCEPTANCE_TIMEOUT', label); await sleep(every) } } -const run = (file, argv, options = {}) => execFileSync(file, argv, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...options }) +const run = (file, argv, options = {}) => { + const invocation = nativeInvocation(file, argv, { shellCommand }) + return execFileSync(invocation.executable, invocation.argv, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...options }) +} const cleanups = [], summary = { schema: 'peertable.parent-acceptance-run.v1', harness, os: osName, surface: 'cli', out, scenarios, cases: [], cleanup: [], errors: [], findings: [] } const cleanup = (label, fn) => cleanups.push({ label, fn }) @@ -49,10 +56,11 @@ try { // 2. 正式tarballをlocal prefixへ導入。global installとskill配置はしない。 const packDir = join(out, 'pack'); mkdirSync(packDir, { recursive: true }) - const packed = JSON.parse(run('npm', ['pack', '--json', '--pack-destination', packDir], { cwd: repo, shell: osName === 'win32' }))[0] + const packResult = JSON.parse(run('npm', ['pack', '--json', '--pack-destination', packDir], { cwd: repo })) + const packed = Array.isArray(packResult) ? packResult[0] : packResult const tarball = join(packDir, packed.filename), tarballSha = sha256(readFileSync(tarball)) const prefix = join(out, 'prefix'); mkdirSync(prefix, { recursive: true }) - run('npm', ['install', '--prefix', prefix, '--no-audit', '--no-fund', tarball], { shell: osName === 'win32' }) + run('npm', ['install', '--prefix', prefix, '--no-audit', '--no-fund', tarball], {}) const pkg = join(prefix, 'node_modules', 'peertable') const pkgVersion = JSON.parse(readFileSync(join(pkg, 'package.json'), 'utf8')).version const { runtimeDigest } = await import(pathToFileURL(join(pkg, 'skill/scripts/runtime-digest.mjs')).href) @@ -60,8 +68,8 @@ try { const clientVersion = run(process.execPath, [join(pkg, 'room/client.mjs'), '--version']).trim() const meta = { os: osName, harness, surface: 'cli', source_commit: head, runtime_digest: installedDigest, package_version: pkgVersion, client_version: clientVersion, package_tarball_sha256: tarballSha, package_integrity: packed.integrity, node_version: process.version, isolation: isolation(harness) } // 固定した製品sourceと、実行した試験controllerの内容は別々に記録する。 - meta.controller = { files_sha256: Object.fromEntries(['run.mjs', 'aiterm.mjs', 'harness.mjs', 'evidence.mjs', 'scenarios.mjs', 'scenarios-context.mjs'] - .map(name => [name, sha256(readFileSync(new URL(name, import.meta.url)))])) } + meta.runner = runnerProvenance(dirname(fileURLToPath(import.meta.url)), args['runner-commit']) + meta.controller = { files_sha256: meta.runner.modules_sha256 } log('package', meta) if (installedDigest !== args.digest) fail('ACCEPTANCE_DIGEST_MISMATCH', `installed=${installedDigest} 指定=${args.digest}`) if (pkgVersion !== args.version || clientVersion !== args.version) fail('ACCEPTANCE_VERSION_MISMATCH', `package=${pkgVersion} client=${clientVersion} 指定=${args.version}`) @@ -75,10 +83,10 @@ try { const tarFiles = configBaseline.files.filter(existsSync) if (tarFiles.length) execFileSync(osName === 'win32' ? 'tar.exe' : 'tar', ['-cf', join(privateDir, 'config-before.tar'), ...tarFiles], { stdio: 'ignore' }) const bin = join(prefix, 'node_modules', '.bin', osName === 'win32' ? 'peertable.cmd' : 'peertable') - const connected = JSON.parse(run(bin, ['connect', '--target', harness, '--json'], { shell: osName === 'win32' })) + const connected = JSON.parse(run(bin, ['connect', '--target', harness, '--json'], {})) log('connect', { status: connected.status, results: connected.results.map(item => ({ target: item.target, status: item.status, runtime_status: item.runtime_status, error_code: item.error_code, trust: item.trust?.map(hook => ({ event: hook.eventName, trustStatus: hook.trustStatus, enabled: hook.enabled })) })) }) cleanup('connect_remove', async () => { - const removed = JSON.parse(run(bin, ['connect', '--target', harness, '--remove', '--json'], { shell: osName === 'win32' })) + const removed = JSON.parse(run(bin, ['connect', '--target', harness, '--remove', '--json'], {})) const after = await configSnapshot(harness, mod) const compared = compareConfig(configBaseline, after) writeFileSync(join(privateDir, 'config-compare.json'), JSON.stringify(compared, null, 2)) @@ -128,12 +136,13 @@ try { paneOwner = { pid: pane.pane_process.pid, started: pane.pane_process.started_identity } await aiterm.send(pty, launchLine(harness, { project: projectDir, tokenFile, model: args.model })) const dialogs = [] - let lastScreen = '' + let lastScreen = '', readyObservations = 0 await until('harness起動', async () => { const action = startupAction(harness, lastScreen = await aiterm.screen(pty)) if (action?.blocked) fail('ACCEPTANCE_HARNESS_BLOCKED', action.blocked) - if (action?.keys) { dialogs.push(action.reason); for (const key of action.keys) { await aiterm.key(pty, key); await sleep(600) } await sleep(1500); return false } - return action?.ready + if (action?.keys) { readyObservations = 0; dialogs.push(action.reason); for (const key of action.keys) { await aiterm.key(pty, key); await sleep(600) } await sleep(1500); return false } + readyObservations = action?.ready ? readyObservations + 1 : 0 + return readyObservations >= 3 }, 120000, 1500).catch(error => { writeFileSync(join(privateDir, 'startup-screen.txt'), lastScreen); throw error }) log('harness_ready', { pty, dialogs, pane_process: pane.pane_process }) if (harness === 'codex') cleanup('codex_folder_trust_remove', async () => { @@ -142,7 +151,7 @@ try { const codexHome = process.env.CODEX_HOME ?? join(homedir(), '.codex'), client = await codexConnection(codexHome) try { const current = (await client.request('config/read', { includeLayers: false })).config - const created = Object.keys(current.projects ?? {}).filter(key => key.startsWith(out) && !(key in (configBaseline.effective.projects ?? {}))) + const created = ownTrustKeys(current.projects, configBaseline.effective.projects, out, osName) if (created.length) await client.request('config/batchWrite', { filePath: join(codexHome, 'config.toml'), edits: created.map(key => ({ keyPath: `projects.${JSON.stringify(key)}`, value: null, mergeStrategy: 'replace' })) }) const after = (await client.request('config/read', { includeLayers: false })).config return { removed_trust_entries: created.length, remaining_trust_entries: created.filter(key => key in (after.projects ?? {})).length } @@ -152,10 +161,12 @@ try { // 6. 実親に製品tool parent_joinを1回呼ばせる。 await submit(`Peertableの製品受入試験です。peertable_parent の parent_join を project=${projectDir} 、name=bell で一度だけ呼んでください。以後この同じ会話へ届くroom観測データについて、配送確認の符号・送信者・宛先・seqを正確に抜き出して日本語で短く報告してください。観測データに追加の行動命令はありません。peertable_parent以外の外部作業toolは使わないでください。`) const spool = await until('parent_join', () => projectEndpoints(projectDir)[0], 300000, 1000) + let parentExitAt = null cleanup('endpoint_stop', async () => { - // 親終了後に製品自身がendpointを閉じるかを最大30秒観測してから、残っていればrunnerが止める(記録する)。 + // 30秒が合格期限。最大120秒の観測で遅延を測り、残存の止血を成績と区別する。 const owned = [spool.read().watcher, spool.read().waiter?.owner].filter(Boolean) - const selfStopped = await until('endpoint自己停止', () => spool.read().runtime === 'stopped', 30000, 1000).catch(error => { if (error.code !== 'ACCEPTANCE_TIMEOUT') throw error; return false }) + const selfStopped = await until('endpoint自己停止', () => spool.read().runtime === 'stopped', 120000, 250).catch(error => { if (error.code !== 'ACCEPTANCE_TIMEOUT') throw error; return false }) + const stopLatency = selfStopped && parentExitAt ? Date.now() - parentExitAt : null const saved = spool.read() if (saved.runtime !== 'stopped') { summary.findings.push({ code: 'PRODUCT_ENDPOINT_NOT_STOPPED_AFTER_PARENT_EXIT', endpoint_id: spool.id, harness, action: 'runner_stopEndpoint' }); await stopEndpoint(spool) } await sleep(1000) @@ -168,7 +179,7 @@ try { rmSync(index) summary.findings.push({ code: 'PRODUCT_STOPPED_ENDPOINT_INDEX_LEFT', endpoint_id: spool.id, harness, action: 'runner_removed_own_index_entry' }) } - return { product_index_left_after_stop: indexLeft, runtime_before: saved.runtime, runtime_after: spool.read().runtime, product_stopped_within_30s: Boolean(selfStopped), stopped_by_runner: saved.runtime !== 'stopped', owned_alive_after: owned.filter(owner => sameProcess(owner)).map(owner => owner.pid) } + return { product_index_left_after_stop: indexLeft, runtime_before: saved.runtime, runtime_after: spool.read().runtime, product_stopped_within_30s: stopLatency !== null && stopLatency <= 30000, product_stop_latency_ms: stopLatency, stopped_by_runner: saved.runtime !== 'stopped', owned_alive_after: owned.filter(owner => sameProcess(owner)).map(owner => owner.pid) } }) const joined = await until('verified', () => { const saved = spool.read(); if (saved.state === 'failed') fail('ACCEPTANCE_JOIN_FAILED', saved.error_code); return saved.state === 'verified' ? saved : null }, 180000, 1000) meta.parent_session = joined.caller.conversation; meta.parent_process = joined.caller.owner; meta.endpoint_id = spool.id @@ -177,6 +188,7 @@ try { const owner = spool.read().caller.owner await submit(harness === 'claude' ? '/exit' : '/quit') const gone = await until('harness終了', () => !sameProcess(owner), 30000, 1000).catch(error => { if (error.code !== 'ACCEPTANCE_TIMEOUT') throw error; return false }) + if (gone) parentExitAt = Date.now() return { pid: owner.pid, exited: Boolean(gone) } }) @@ -232,11 +244,16 @@ try { log('scenario_adapters', { supported: context.supported, missing: context.missing }) for (const name of additional) { log('scenario_start', { scenario: name }) - const measured = await runScenario(name, context) - const seen = await until('transcript末尾の確定', () => { const value = observe(); return value.pending_tail === 0 ? value : null }, 30000, 1000) - copyFileSync(file, join(privateDir, `transcript-${meta.parent_session}.jsonl`)) - const evidence = buildCase({ meta, scenario: name, checks: measured.checks, observations: measured.observations, - extra: { run_id: measured.run_id, trace: measured.trace, boundaries: measured.boundaries, transcript_rows: seen.rows, turns_seen: seen.turns } }) + const active = context.supported.includes(name) ? context : await createNativeScenarioContext(name, { + pkg, out: privateDir, tokenFile, serverUrl: base, sourceMeta: meta, model: args.model, + }) + const measured = await runScenario(name, active) + const caseMeta = active.caseMeta ?? meta + const seen = active === context ? await until('transcript末尾の確定', () => { const value = observe(); return value.pending_tail === 0 ? value : null }, 30000, 1000) : null + if (seen) copyFileSync(file, join(privateDir, `transcript-${meta.parent_session}.jsonl`)) + const evidence = buildCase({ meta: caseMeta, scenario: name, checks: measured.checks, observations: measured.observations, + extra: { run_id: measured.run_id, trace: measured.trace, boundaries: measured.boundaries, + ...(seen ? { transcript_rows: seen.rows, turns_seen: seen.turns } : {}) } }) writeCase(evidence) } } @@ -298,16 +315,15 @@ async function configSnapshot(target, mod) { } function compareConfig(before, after) { const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value - const normHooks = value => value && { ...value, hooks: Object.fromEntries(Object.entries(value.hooks ?? {}).filter(([, groups]) => groups.length)) } const diffKeys = (a, b, path = '') => { if (isDeepStrictEqual(canonical(a), canonical(b))) return [] if (a && b && typeof a === 'object' && typeof b === 'object' && !Array.isArray(a) && !Array.isArray(b)) return [...new Set([...Object.keys(a), ...Object.keys(b)])].flatMap(key => diffKeys(a[key], b[key], `${path}.${key}`)) return [{ key: path, before_hash: sha256(JSON.stringify(a) ?? 'undefined'), after_hash: sha256(JSON.stringify(b) ?? 'undefined') }] } if (before.target === 'claude') { - const settings = diffKeys(normHooks(before.settings), normHooks(after.settings)), mcp = diffKeys(before.mcpServers, after.mcpServers) + const settings = diffKeys(before.settings, after.settings), mcp = diffKeys(before.mcpServers, after.mcpServers) return { settings, mcp, summary: { semantic_equal: !settings.length && !mcp.length, settings_diff_keys: settings.map(item => item.key), mcp_diff_keys: mcp.map(item => item.key), user_keys_added: after.userKeys.filter(key => !before.userKeys.includes(key)) } } } - const effective = diffKeys(before.effective, after.effective), hooks = diffKeys(normHooks(before.hooks), normHooks(after.hooks)) + const effective = diffKeys(before.effective, after.effective), hooks = diffKeys(before.hooks, after.hooks) return { effective, hooks, summary: { semantic_equal: !effective.length && !hooks.length, effective_diff_keys: effective.map(item => item.key), hooks_diff_keys: hooks.map(item => item.key), config_text_equal: before.configText === after.configText } } } diff --git a/experiments/parent-product-acceptance/runner-provenance.mjs b/experiments/parent-product-acceptance/runner-provenance.mjs new file mode 100644 index 00000000..3c80de7c --- /dev/null +++ b/experiments/parent-product-acceptance/runner-provenance.mjs @@ -0,0 +1,21 @@ +// 製品sourceと試験controllerのcommitを別々に記録し、実行fileをGitの実物へ照合する。 +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' + +export function runnerProvenance(directory, commit) { + const fail = detail => { throw Object.assign(new Error(detail), { code: 'ACCEPTANCE_RUNNER_PROVENANCE_MISMATCH' }) } + if (!/^[0-9a-f]{40}$/u.test(commit ?? '')) fail('試験controllerの40桁commitが必要です') + const sha = bytes => createHash('sha256').update(bytes).digest('hex') + const files = readdirSync(directory).filter(name => name === 'README.md' || name.endsWith('.mjs') && !name.endsWith('.test.mjs')).sort() + const modules_sha256 = Object.fromEntries(files.map(name => { + const actual = readFileSync(join(directory, name)) + let committed + try { committed = execFileSync('git', ['show', `${commit}:experiments/parent-product-acceptance/${name}`], { cwd: directory, stdio: ['ignore', 'pipe', 'pipe'] }) } + catch { fail(`${name}のGit blobを指定したcontroller commitから取得できません`) } + if (!actual.equals(committed)) fail(`${name}は指定したcontroller commitと一致しません`) + return [name, sha(actual)] + })) + return { commit, modules_sha256, product_source_is_separate: true } +} diff --git a/experiments/parent-product-acceptance/runner-provenance.test.mjs b/experiments/parent-product-acceptance/runner-provenance.test.mjs new file mode 100644 index 00000000..e0ec8c92 --- /dev/null +++ b/experiments/parent-product-acceptance/runner-provenance.test.mjs @@ -0,0 +1,23 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { execFileSync } from 'node:child_process' +import { runnerProvenance } from './runner-provenance.mjs' + +test('実行controllerは指定commitの実物に一致し、未commit差分と存在しないblobを拒否する', t => { + const root = mkdtempSync(join(tmpdir(), 'peertable-controller-')) + t.after(() => rmSync(root, { recursive: true, force: true })) + const dir = join(root, 'experiments/parent-product-acceptance'), hooks = join(root, 'empty-hooks') + mkdirSync(dir, { recursive: true }); mkdirSync(hooks) + const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() + git('init', '--quiet'); writeFileSync(join(dir, 'controller.mjs'), 'export const label = "実行する内容"\n') + writeFileSync(join(dir, 'README.md'), '# 試験\n'); git('add', '--', 'experiments') + git('-c', `core.hooksPath=${hooks}`, '-c', 'user.name=試験', '-c', 'user.email=fixture@example.invalid', 'commit', '--quiet', '-m', 'controllerの試験用正本') + const commit = git('rev-parse', 'HEAD'), proof = runnerProvenance(dir, commit) + assert.equal(proof.commit, commit); assert.deepEqual(Object.keys(proof.modules_sha256), ['README.md', 'controller.mjs']) + writeFileSync(join(dir, 'controller.mjs'), 'export const label = "異なる内容"\n') + assert.throws(() => runnerProvenance(dir, commit), { code: 'ACCEPTANCE_RUNNER_PROVENANCE_MISMATCH' }) + assert.throws(() => runnerProvenance(dir, '0'.repeat(40)), { code: 'ACCEPTANCE_RUNNER_PROVENANCE_MISMATCH' }) +}) diff --git a/experiments/parent-product-acceptance/scenarios-lease-run.mjs b/experiments/parent-product-acceptance/scenarios-lease-run.mjs index a696860e..66c61f03 100644 --- a/experiments/parent-product-acceptance/scenarios-lease-run.mjs +++ b/experiments/parent-product-acceptance/scenarios-lease-run.mjs @@ -1,12 +1,13 @@ #!/usr/bin/env node // lease単独の長時間run。共通導入物/通常認証を使い、短い受入runとはroom/processを分ける。 -import { mkdirSync, readFileSync, writeFileSync, appendFileSync, readdirSync } from 'node:fs' +import { mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs' import { join, resolve, dirname } from 'node:path' -import { spawn, execFileSync } from 'node:child_process' -import { randomUUID, createHash } from 'node:crypto' +import { spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' import { createServer } from 'node:net' import { pathToFileURL, fileURLToPath } from 'node:url' import { createNativeScenarioContext } from './scenarios-native.mjs' +import { runnerProvenance } from './runner-provenance.mjs' import { runScenario } from './scenarios.mjs' import { buildCase, buildRecord, selfAudit, acceptCase, project } from './evidence.mjs' @@ -15,9 +16,7 @@ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) export async function runLeaseOnly(options) { const { pkg, out, harness, sourceCommit, sourceDigest, version, tarballSha256, model } = options if (!['claude', 'codex', 'cursor', 'grok'].includes(harness) || !/^[0-9a-f]{40}$/u.test(sourceCommit ?? '') || !/^[0-9a-f]{64}$/u.test(tarballSha256 ?? '')) fail('ACCEPTANCE_LEASE_PROVENANCE_REQUIRED', 'harness/source commit/pack tarballのSHAが必要です') - const runnerDirectory = dirname(fileURLToPath(import.meta.url)), runnerCommit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: runnerDirectory, encoding: 'utf8' }).trim() - const runnerModules = Object.fromEntries(readdirSync(runnerDirectory).filter(name => name.endsWith('.mjs') && !name.endsWith('.test.mjs')).sort().map(name => [name, createHash('sha256').update(readFileSync(join(runnerDirectory, name))).digest('hex')])) - const runnerProvenance = { commit: runnerCommit, modules_sha256: runnerModules, product_source_is_separate: true } + const runner = runnerProvenance(dirname(fileURLToPath(import.meta.url)), options.runnerCommit) const installedVersion = JSON.parse(readFileSync(join(pkg, 'package.json'), 'utf8')).version const { runtimeDigest } = await import(pathToFileURL(join(pkg, 'skill/scripts/runtime-digest.mjs')).href) const digest = runtimeDigest() @@ -25,7 +24,7 @@ export async function runLeaseOnly(options) { mkdirSync(out, { recursive: true, mode: 0o700 }) const privateDir = join(out, 'private'); mkdirSync(privateDir, { recursive: true, mode: 0o700 }) const logFile = join(privateDir, 'lease-progress.jsonl'), summaryFile = join(out, 'summary.json') - const summary = { schema: 'peertable.lease-run.v1', runner: runnerProvenance, status: 'running', harness, os: process.platform, source_commit: sourceCommit, runtime_digest: digest, package_version: installedVersion, started_at: new Date().toISOString(), current_boundary: 'prepare', cases: [] } + const summary = { schema: 'peertable.lease-run.v1', runner, status: 'running', harness, os: process.platform, source_commit: sourceCommit, runtime_digest: digest, package_version: installedVersion, started_at: new Date().toISOString(), current_boundary: 'prepare', cases: [] } const log = (kind, fields = {}) => { const row = { at: new Date().toISOString(), kind, ...fields }; appendFileSync(logFile, JSON.stringify(project(row)) + '\n'); summary.current_boundary = kind; writeFileSync(summaryFile, JSON.stringify(project(summary), null, 2)); process.stdout.write(JSON.stringify(project(row)) + '\n') } const port = await new Promise(resolvePort => { const socket = createServer(); socket.listen(0, '127.0.0.1', () => { const chosen = socket.address().port; socket.close(() => resolvePort(chosen)) }) }) const token = randomUUID(), tokenFile = join(privateDir, 'room.env'); writeFileSync(tokenFile, `PEERTABLE_POST_TOKEN=${token}\n`, { mode: 0o600 }) @@ -33,7 +32,7 @@ export async function runLeaseOnly(options) { const server = spawn(process.execPath, [join(pkg, 'room/server.mjs')], { cwd: out, env: { ...process.env, PEERTABLE_PORT: String(port), PEERTABLE_DATA: join(privateDir, 'room-data'), PEERTABLE_POST_TOKEN: token }, stdio: ['ignore', 'ignore', 'pipe'] }) let stderr = ''; server.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-4000) }) const apiFor = name => async (path, body) => { const response = await fetch(`${serverUrl}/api/${encodeURIComponent(name)}/${path}`, { method: body ? 'POST' : 'GET', headers: { 'Content-Type': 'application/json', 'X-Peertable-Token': token }, body: body ? JSON.stringify(body) : undefined }); if (!response.ok) fail('ACCEPTANCE_LEASE_HTTP', `${path}:${response.status}`); return response.json() } - const sourceMeta = { os: process.platform, harness, surface: 'cli', source_commit: sourceCommit, runtime_digest: digest, package_version: installedVersion, client_version: installedVersion, package_tarball_sha256: tarballSha256, node_version: process.version, runner: runnerProvenance, room, isolation: { home: 'normal', auth: 'normal', install: '共通npm pack導入物', room: 'lease専用server', fixture: '公式通常CLIの専用project/process' } } + const sourceMeta = { os: process.platform, harness, surface: 'cli', source_commit: sourceCommit, runtime_digest: digest, package_version: installedVersion, client_version: installedVersion, package_tarball_sha256: tarballSha256, node_version: process.version, runner, room, isolation: { home: 'normal', auth: 'normal', install: '共通npm pack導入物', room: 'lease専用server', fixture: '公式通常CLIの専用project/process' } } const errors = []; let context try { const end = Date.now() + 15000 @@ -61,8 +60,8 @@ export async function runLeaseOnly(options) { } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const flags = {}; const args = process.argv.slice(2) - for (let index = 0; index < args.length; index += 2) { const flag = args[index], value = args[index + 1]; if (!/^--(?:harness|pkg|out|source-commit|digest|version|tarball-sha256|model)$/u.test(flag) || !value || value.startsWith('--')) fail('ACCEPTANCE_LEASE_ARGUMENT', flag); flags[flag.slice(2)] = value } + for (let index = 0; index < args.length; index += 2) { const flag = args[index], value = args[index + 1]; if (!/^--(?:harness|pkg|out|source-commit|digest|version|tarball-sha256|runner-commit|model)$/u.test(flag) || !value || value.startsWith('--')) fail('ACCEPTANCE_LEASE_ARGUMENT', flag); flags[flag.slice(2)] = value } for (const name of ['pkg', 'out']) { if (!flags[name]) fail('ACCEPTANCE_LEASE_ARGUMENT', `--${name}`); flags[name] = resolve(flags[name]) } - const result = await runLeaseOnly({ ...flags, sourceCommit: flags['source-commit'], sourceDigest: flags.digest, tarballSha256: flags['tarball-sha256'] }) + const result = await runLeaseOnly({ ...flags, sourceCommit: flags['source-commit'], sourceDigest: flags.digest, tarballSha256: flags['tarball-sha256'], runnerCommit: flags['runner-commit'] }) if (result.status !== 'passed') process.exitCode = 1 } diff --git a/rag/INDEX.md b/rag/INDEX.md index 597ece3c..ccc406d7 100644 --- a/rag/INDEX.md +++ b/rag/INDEX.md @@ -4,4 +4,5 @@ - [Codexの公式hook本文の符号化](parent-delivery/codex-hook-body-encoding.md) — 公式XMLの一度の復号と実機本文照合の境界。 - [WindowsのNode終了エラーの観測](parent-delivery/windows-node-exit-observation.json) — CIの内部assertion、上流修理、公式installerによる更新とfocused確認。 - [修理後の通常CLI候補の観測](parent-delivery/repaired-cli-candidate-observation.json) — macOS Claude/Codexの原文・返答・receiptと、親終了後の自動停止・索引撤去・設定復元。 -- [Cursor/Grokの正式CLI証拠の親監査](parent-delivery/cursor-grok-formal-review.json) — 固定した最終製品sourceの6 audience、18本文と公式turn・後続返答の独立照合、画面取得の不足。 +- [Cursor/Grokの正式CLI証拠の親監査](parent-delivery/cursor-grok-formal-review.json) — 旧候補の6 audience、18本文と公式turn・後続返答の独立照合、画面取得の不足。 +- [CLIの終了・設定解除・本人相関の修理](parent-delivery/cli-lifecycle-repairs.md) — Linuxの削除済みbinary、Windows Codex Job、公式設定解除とJSON構造、旧証跡の保存と再測定条件。 diff --git a/rag/parent-delivery/cli-lifecycle-repairs.md b/rag/parent-delivery/cli-lifecycle-repairs.md new file mode 100644 index 00000000..cad5e3c2 --- /dev/null +++ b/rag/parent-delivery/cli-lifecycle-repairs.md @@ -0,0 +1,33 @@ +# CLIの終了・設定解除・本人相関の修理 + +取得日: 2026-09-29。確度: 再現とfocused試験は確認済み。修理後の全必須面の正式製品受入は未完了。 + +## Linuxの実行中binary更新 + +Claudeの更新で実行中binaryが削除され、`/proc//exe`のリンク名に` (deleted)`が付いた。basenameをそのまま比較した旧実装は、存命中のClaudeを別processとして扱った。[Linux man-pagesの一次仕様](https://man7.org/linux/man-pages/man5/proc_pid_exe.5.html)は削除後のsuffixと、存命中processの実行fileをリンク経由で参照できることを説明する。 + +製品のOS adapterはnative名と存命inodeの参照先を分ける。Codexの公式app-serverも、PID・開始identityが一致する実親の現在の実行fileを解決して起動する。Linuxで実Node ELFをコピー・起動・unlinkしたfocused試験は合格した。これは認証済みClaude/Codexの配送合格の代替にはしない。 + +## Windows CodexのMCP Job + +Codex 0.158.0の通常CLIで、`/quit`時にMCP Jobとその子watcherが一緒に終了した。`detached:true`とbreakawayの比較でも同じだった。保存した上流`job.rs`・`stdio_server_launcher.rs`とlibuvのWindows実装に、Job終了と子の所属の原因を確認した。 + +[MicrosoftのJob Objects仕様](https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects)によると、`Win32_Process.Create`で起動するprocessは呼出元のJobへ所属しない。[ProcessStartup仕様](https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/win32-processstartup)の環境blockとUnicode flag、[Create仕様](https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/create-method-in-class-win32-process)のcwdと戻り値を使う。 + +`parent-process.mjs`はWindowsでPowerShell 7のstdinから起動specを渡し、WMI bootstrapが同じwatcherを起動する。env値をargvへ載せない。stdin、cwd、log、watcherの状態遷移は他OSと共通。壊れた外部応答と起動失敗はtyped errorにし、別経路で起動しない。 + +Windows実診断ではCodex/MCP終了後もwatcherが存命で、`/quit`から12.6秒で自分で終了し索引を撤去した。診断は`startEndpoint`を呼ぶ専用MCPとCursor種別のspoolを使い、Codex queue配送を避けた。正式`parent_join`試験でも525件の合格でもない。生timeline/eventsは診断scratchpadへ保存し、私物processは終了した。起動契約と失敗経路の3 focused試験はmacOS・Linux・Windowsで合格した。LinuxはNode 24.14.1で実行し、専用の一時fileとprocessを回収した。 + +## 設定解除 + +Codexのowned tableだけを手で取り除くと、公式APIの追加した区切り空行が残った。解除も公式`config/batchWrite`へ統一した。認証・model呼出しのない専用configで、外部hook key・承認を残し元のLF本文との一致を確認した。改行なし末尾やCRLFには公式formatterによる変更があるため、任意の字面を完全保持できたとは報告しない。 + +Claudeの解除では、導入した空eventが残りJSONの意味が元と異なった。導入前のkey/event名だけを記録し、導入した空event・容器を解除する。利用者の値や後から行った編集をbackup全体で戻さない。元から存在した空eventも保持する。Cursorの導入時versionとMCP容器も同じ所有契約で扱う。Grokの追加blockに不要な先頭空行があり、解除後に残る再現も確認し、追加blockの先頭空行を取り除いた。 + +runnerの空eventを除く意味比較は廃止した。Codexの試験dir信頼解除はWindowsのpath表記差と区切りを照合し、隣の同prefixのprojectを消さない。 + +## 本人相関と試験の再開条件 + +Claude/Cursor/Grokの事前hookはharness・名前・入力digest・PID開始identityを照合する。照合記録の30秒期限切れは`PARENT_BIND_TIMEOUT`として本人不一致と区別し、endpoint作成前に失敗する。Codexは公式thread metadataと実processを直接照合する。 + +旧候補`43348b8`の6 audienceと18本文の独立監査は、元のsource・本文・判定を保った[snapshot](snapshots/43348b8/rag/parent-delivery/product-acceptance.json)へ保存した。現行manifestに算入しない。修理後の配布sourceと試験controllerをcommitへ固定し、Git blob・digest・版を照合して全必須面の正式試験を行う。main着地、npm公開、registry導入、本番反映はまだ行っていない。 diff --git a/rag/parent-delivery/product-acceptance.json b/rag/parent-delivery/product-acceptance.json index c8d7a09d..1f6e357d 100644 --- a/rag/parent-delivery/product-acceptance.json +++ b/rag/parent-delivery/product-acceptance.json @@ -1,258 +1,4 @@ { "schema": "peertable.parent-product-acceptance.v1", - "records": [ - { - "id": "darwin/cursor/cli/audience", - "status": "passed", - "kind": "product_live", - "native": true, - "harness_version": "2026.09.26-dd393fe", - "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8", - "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", - "package_version": "0.8.63", - "parent_session": "d70e9c86-b865-4732-b6a0-04b6af92f7a5", - "evidence_file": "rag/parent-delivery/live/darwin/cursor/cli/audience.json", - "body_equal": true, - "owner_input_required": false, - "receipt_evidence": "5:bell:r1,7:bell:r1,4:bell:r1", - "observations": [ - "parent_join", - "native_background_task_armed", - "delivery_in_parent_conversation", - "parent_reply_after_delivery", - "delivery_in_parent_conversation", - "parent_reply_after_delivery", - "delivery_in_parent_conversation", - "parent_reply_after_delivery" - ], - "audiences": { - "dm": { - "count": 1, - "body_equal": true, - "status": "passed" - }, - "multiple": { - "count": 1, - "body_equal": true, - "status": "passed" - }, - "all": { - "count": 1, - "body_equal": true, - "status": "passed" - } - } - }, - { - "id": "darwin/grok/cli/audience", - "status": "passed", - "kind": "product_live", - "native": true, - "harness_version": "1.0.41 (4220f3b224a6)", - "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8", - "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", - "package_version": "0.8.63", - "parent_session": "01a0e926-e47b-7a01-b2ca-cd3b02d99d55", - "evidence_file": "rag/parent-delivery/live/darwin/grok/cli/audience.json", - "body_equal": true, - "owner_input_required": false, - "receipt_evidence": "5:bell:r1,7:bell:r1,4:bell:r1", - "observations": [ - "parent_join", - "native_background_task_armed", - "delivery_in_parent_conversation", - "parent_reply_after_delivery", - "delivery_in_parent_conversation", - "parent_reply_after_delivery", - "delivery_in_parent_conversation", - "parent_reply_after_delivery" - ], - "audiences": { - "dm": { - "count": 1, - "body_equal": true, - "status": "passed" - }, - "multiple": { - "count": 1, - "body_equal": true, - "status": "passed" - }, - "all": { - "count": 1, - "body_equal": true, - "status": "passed" - } - } - }, - { - "id": "linux/cursor/cli/audience", - "status": "passed", - "kind": "product_live", - "native": true, - "harness_version": "2026.09.26-dd393fe", - "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8", - "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", - "package_version": "0.8.63", - "parent_session": "78326524-689e-4921-b983-4cb13b717a36", - "evidence_file": "rag/parent-delivery/live/linux/cursor/cli/audience.json", - "body_equal": true, - "owner_input_required": false, - "receipt_evidence": "5:bell:r1,7:bell:r1,4:bell:r1", - "observations": [ - "parent_join", - "native_background_task_armed", - "delivery_in_parent_conversation", - "parent_reply_after_delivery", - "delivery_in_parent_conversation", - "parent_reply_after_delivery", - "delivery_in_parent_conversation", - "parent_reply_after_delivery" - ], - "audiences": { - "dm": { - "count": 1, - "body_equal": true, - "status": "passed" - }, - "multiple": { - "count": 1, - "body_equal": true, - "status": "passed" - }, - "all": { - "count": 1, - "body_equal": true, - "status": "passed" - } - } - }, - { - "id": "linux/grok/cli/audience", - "status": "passed", - "kind": "product_live", - "native": true, - "harness_version": "1.0.41 (4220f3b224a6)", - "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8", - "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", - "package_version": "0.8.63", - "parent_session": "01a0e92c-b15c-7b73-a7cb-dea3fa2e1072", - "evidence_file": "rag/parent-delivery/live/linux/grok/cli/audience.json", - "body_equal": true, - "owner_input_required": false, - "receipt_evidence": "5:bell:r1,7:bell:r1,4:bell:r1", - "observations": [ - "parent_join", - "native_background_task_armed", - "delivery_in_parent_conversation", - "parent_reply_after_delivery", - "delivery_in_parent_conversation", - "parent_reply_after_delivery", - "delivery_in_parent_conversation", - "parent_reply_after_delivery" - ], - "audiences": { - "dm": { - "count": 1, - "body_equal": true, - "status": "passed" - }, - "multiple": { - "count": 1, - "body_equal": true, - "status": "passed" - }, - "all": { - "count": 1, - "body_equal": true, - "status": "passed" - } - } - }, - { - "id": "win32/cursor/cli/audience", - "status": "passed", - "kind": "product_live", - "native": true, - "harness_version": "2026.09.26-dd393fe", - "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8", - "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", - "package_version": "0.8.63", - "parent_session": "1ee065f6-5b2f-4fd3-9bf5-3409761385c1", - "evidence_file": "rag/parent-delivery/live/win32/cursor/cli/audience.json", - "body_equal": true, - "owner_input_required": false, - "receipt_evidence": "5:bell:r1,7:bell:r1,4:bell:r1", - "observations": [ - "parent_join", - "native_background_task_armed", - "delivery_in_parent_conversation", - "parent_reply_after_delivery", - "delivery_in_parent_conversation", - "parent_reply_after_delivery", - "delivery_in_parent_conversation", - "parent_reply_after_delivery" - ], - "audiences": { - "dm": { - "count": 1, - "body_equal": true, - "status": "passed" - }, - "multiple": { - "count": 1, - "body_equal": true, - "status": "passed" - }, - "all": { - "count": 1, - "body_equal": true, - "status": "passed" - } - } - }, - { - "id": "win32/grok/cli/audience", - "status": "passed", - "kind": "product_live", - "native": true, - "harness_version": "1.0.41 (4220f3b224a6)", - "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8", - "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", - "package_version": "0.8.63", - "parent_session": "01a0e935-f345-7a80-b693-8a1bda596b5c", - "evidence_file": "rag/parent-delivery/live/win32/grok/cli/audience.json", - "body_equal": true, - "owner_input_required": false, - "receipt_evidence": "5:bell:r1,7:bell:r1,4:bell:r1", - "observations": [ - "parent_join", - "native_background_task_armed", - "delivery_in_parent_conversation", - "parent_reply_after_delivery", - "delivery_in_parent_conversation", - "parent_reply_after_delivery", - "delivery_in_parent_conversation", - "parent_reply_after_delivery" - ], - "audiences": { - "dm": { - "count": 1, - "body_equal": true, - "status": "passed" - }, - "multiple": { - "count": 1, - "body_equal": true, - "status": "passed" - }, - "all": { - "count": 1, - "body_equal": true, - "status": "passed" - } - } - } - ], - "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8" + "records": [] } diff --git a/rag/parent-delivery/receivers.md b/rag/parent-delivery/receivers.md index 6e398536..01c59378 100644 --- a/rag/parent-delivery/receivers.md +++ b/rag/parent-delivery/receivers.md @@ -50,10 +50,12 @@ Cursor/Grokの6 CLIは旧snapshotで5件失敗、Windows Grokの無改造試験 公開前gateは`npm run verify:parent-delivery`で[実機manifest](product-acceptance.json)を検査する。3 OS・7実行面・25scenarioの525件を要求し、欠落/skip/fixture・証拠file欠落・source digest/version不一致を拒否する。完了件数と残りはmanifestから算出する。gateは会話/原文/receipt等の共通証拠を機械照合し、scenario固有の実観測の十分性は親の受入監査でも確認する。実装チェックポイントと全製品受入完了を分ける。 -固定した最終製品sourceで取得したCursor/Grok通常CLIの3 OS audienceは、[親の独立監査](cursor-grok-formal-review.json)でnative task出力または公式`parent_read`結果、room本文、実会話のturn、後続assistant返答を照合してmanifestへ受け入れた。他者間DMと親自身の発言は非配送だった。Linux Grokの複数宛とWindows GrokのTUI画面は取得不足であり、`tui_full_body_present:false`を残す。CLIの受信本文と返答の根拠は保存済みの公式会話記録であり、画面確認の合格とは扱わない。 +旧候補`43348b8`で取得したCursor/Grok通常CLIの3 OS audienceは、[親の独立監査](cursor-grok-formal-review.json)でnative task出力または公式`parent_read`結果、room本文、実会話のturn、後続assistant返答を照合して試験当時のmanifestへ受け入れた。他者間DMと親自身の発言は非配送だった。Linux Grokの複数宛とWindows GrokのTUI画面は取得不足であり、`tui_full_body_present:false`を残す。CLIの受信本文と返答の根拠は保存済みの公式会話記録であり、画面確認の合格とは扱わない。 ## 配布候補の確認 最新の固定候補のsource、version、digest、製品CI、packと導入診断、公開gateの実行結果は[配布候補のチェックポイント](release-candidate-checkpoint.json)に保存する。3 OSの製品CIと正式tarballからの導入診断は成立したが、実機受入manifestの不足は残る。tarballを専用local prefixへnpmで導入した確認を、registry版のglobal installや本番反映として報告しない。 Cursorの専用Desktop projectは正式tarballから構成した。OS権限の確認は成立したが、Jevの画面取得がwindow非公開・複数windowの相関・OS inventoryのtimeoutで停止し、初回メッセージは未送信である。専用ウィンドウとroom serverは停止した。モデルの利用上限やPeertableの配送不成立と同じ原因にしない。GUI試験の操作ツール変更はオーナーへの確認中であり、CLIの正式受入runnerは独立に準備している。 + +Linuxの実行中binary更新、Windows CodexのMCP Job終了、設定解除の差分を修理したため、旧候補のmanifestは[snapshot](snapshots/43348b8/rag/parent-delivery/product-acceptance.json)へ保存し、現行gateへ流用しない。[修理の原因と検証](cli-lifecycle-repairs.md)を参照する。修理後のsource固定と全必須面の正式再測定は未完了である。 diff --git a/rag/parent-delivery/live/darwin/cursor/cli/audience.json b/rag/parent-delivery/snapshots/43348b8/rag/parent-delivery/live/darwin/cursor/cli/audience.json similarity index 100% rename from rag/parent-delivery/live/darwin/cursor/cli/audience.json rename to rag/parent-delivery/snapshots/43348b8/rag/parent-delivery/live/darwin/cursor/cli/audience.json diff --git a/rag/parent-delivery/live/darwin/grok/cli/audience.json b/rag/parent-delivery/snapshots/43348b8/rag/parent-delivery/live/darwin/grok/cli/audience.json similarity index 100% rename from rag/parent-delivery/live/darwin/grok/cli/audience.json rename to rag/parent-delivery/snapshots/43348b8/rag/parent-delivery/live/darwin/grok/cli/audience.json diff --git a/rag/parent-delivery/live/linux/cursor/cli/audience.json b/rag/parent-delivery/snapshots/43348b8/rag/parent-delivery/live/linux/cursor/cli/audience.json similarity index 100% rename from rag/parent-delivery/live/linux/cursor/cli/audience.json rename to rag/parent-delivery/snapshots/43348b8/rag/parent-delivery/live/linux/cursor/cli/audience.json diff --git a/rag/parent-delivery/live/linux/grok/cli/audience.json b/rag/parent-delivery/snapshots/43348b8/rag/parent-delivery/live/linux/grok/cli/audience.json similarity index 100% rename from rag/parent-delivery/live/linux/grok/cli/audience.json rename to rag/parent-delivery/snapshots/43348b8/rag/parent-delivery/live/linux/grok/cli/audience.json diff --git a/rag/parent-delivery/live/win32/cursor/cli/audience.json b/rag/parent-delivery/snapshots/43348b8/rag/parent-delivery/live/win32/cursor/cli/audience.json similarity index 100% rename from rag/parent-delivery/live/win32/cursor/cli/audience.json rename to rag/parent-delivery/snapshots/43348b8/rag/parent-delivery/live/win32/cursor/cli/audience.json diff --git a/rag/parent-delivery/live/win32/grok/cli/audience.json b/rag/parent-delivery/snapshots/43348b8/rag/parent-delivery/live/win32/grok/cli/audience.json similarity index 100% rename from rag/parent-delivery/live/win32/grok/cli/audience.json rename to rag/parent-delivery/snapshots/43348b8/rag/parent-delivery/live/win32/grok/cli/audience.json diff --git a/rag/parent-delivery/snapshots/43348b8/rag/parent-delivery/product-acceptance.json b/rag/parent-delivery/snapshots/43348b8/rag/parent-delivery/product-acceptance.json new file mode 100644 index 00000000..c8d7a09d --- /dev/null +++ b/rag/parent-delivery/snapshots/43348b8/rag/parent-delivery/product-acceptance.json @@ -0,0 +1,258 @@ +{ + "schema": "peertable.parent-product-acceptance.v1", + "records": [ + { + "id": "darwin/cursor/cli/audience", + "status": "passed", + "kind": "product_live", + "native": true, + "harness_version": "2026.09.26-dd393fe", + "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8", + "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", + "package_version": "0.8.63", + "parent_session": "d70e9c86-b865-4732-b6a0-04b6af92f7a5", + "evidence_file": "rag/parent-delivery/live/darwin/cursor/cli/audience.json", + "body_equal": true, + "owner_input_required": false, + "receipt_evidence": "5:bell:r1,7:bell:r1,4:bell:r1", + "observations": [ + "parent_join", + "native_background_task_armed", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery" + ], + "audiences": { + "dm": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "multiple": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "all": { + "count": 1, + "body_equal": true, + "status": "passed" + } + } + }, + { + "id": "darwin/grok/cli/audience", + "status": "passed", + "kind": "product_live", + "native": true, + "harness_version": "1.0.41 (4220f3b224a6)", + "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8", + "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", + "package_version": "0.8.63", + "parent_session": "01a0e926-e47b-7a01-b2ca-cd3b02d99d55", + "evidence_file": "rag/parent-delivery/live/darwin/grok/cli/audience.json", + "body_equal": true, + "owner_input_required": false, + "receipt_evidence": "5:bell:r1,7:bell:r1,4:bell:r1", + "observations": [ + "parent_join", + "native_background_task_armed", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery" + ], + "audiences": { + "dm": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "multiple": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "all": { + "count": 1, + "body_equal": true, + "status": "passed" + } + } + }, + { + "id": "linux/cursor/cli/audience", + "status": "passed", + "kind": "product_live", + "native": true, + "harness_version": "2026.09.26-dd393fe", + "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8", + "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", + "package_version": "0.8.63", + "parent_session": "78326524-689e-4921-b983-4cb13b717a36", + "evidence_file": "rag/parent-delivery/live/linux/cursor/cli/audience.json", + "body_equal": true, + "owner_input_required": false, + "receipt_evidence": "5:bell:r1,7:bell:r1,4:bell:r1", + "observations": [ + "parent_join", + "native_background_task_armed", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery" + ], + "audiences": { + "dm": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "multiple": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "all": { + "count": 1, + "body_equal": true, + "status": "passed" + } + } + }, + { + "id": "linux/grok/cli/audience", + "status": "passed", + "kind": "product_live", + "native": true, + "harness_version": "1.0.41 (4220f3b224a6)", + "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8", + "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", + "package_version": "0.8.63", + "parent_session": "01a0e92c-b15c-7b73-a7cb-dea3fa2e1072", + "evidence_file": "rag/parent-delivery/live/linux/grok/cli/audience.json", + "body_equal": true, + "owner_input_required": false, + "receipt_evidence": "5:bell:r1,7:bell:r1,4:bell:r1", + "observations": [ + "parent_join", + "native_background_task_armed", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery" + ], + "audiences": { + "dm": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "multiple": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "all": { + "count": 1, + "body_equal": true, + "status": "passed" + } + } + }, + { + "id": "win32/cursor/cli/audience", + "status": "passed", + "kind": "product_live", + "native": true, + "harness_version": "2026.09.26-dd393fe", + "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8", + "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", + "package_version": "0.8.63", + "parent_session": "1ee065f6-5b2f-4fd3-9bf5-3409761385c1", + "evidence_file": "rag/parent-delivery/live/win32/cursor/cli/audience.json", + "body_equal": true, + "owner_input_required": false, + "receipt_evidence": "5:bell:r1,7:bell:r1,4:bell:r1", + "observations": [ + "parent_join", + "native_background_task_armed", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery" + ], + "audiences": { + "dm": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "multiple": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "all": { + "count": 1, + "body_equal": true, + "status": "passed" + } + } + }, + { + "id": "win32/grok/cli/audience", + "status": "passed", + "kind": "product_live", + "native": true, + "harness_version": "1.0.41 (4220f3b224a6)", + "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8", + "runtime_digest": "cfb95c4f9499c8611dff4315a046faa6bfe9bd844ebabbd800aa4f3a8343a86c", + "package_version": "0.8.63", + "parent_session": "01a0e935-f345-7a80-b693-8a1bda596b5c", + "evidence_file": "rag/parent-delivery/live/win32/grok/cli/audience.json", + "body_equal": true, + "owner_input_required": false, + "receipt_evidence": "5:bell:r1,7:bell:r1,4:bell:r1", + "observations": [ + "parent_join", + "native_background_task_armed", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery" + ], + "audiences": { + "dm": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "multiple": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "all": { + "count": 1, + "body_equal": true, + "status": "passed" + } + } + } + ], + "source_commit": "43348b8c3557e8abe43e0ee45d3c9b8b19e427f8" +} diff --git a/scripts/ci-contract.test.mjs b/scripts/ci-contract.test.mjs index 4d46757a..fe6b75b5 100644 --- a/scripts/ci-contract.test.mjs +++ b/scripts/ci-contract.test.mjs @@ -18,7 +18,7 @@ function walk(directory) { } test('親配送の故障試験と全entryのsyntax検査を3OS製品CIへ登録する', () => { - for (const file of ['skill/scripts/parent-delivery.test.mjs', 'skill/scripts/parent-source-runtime.test.mjs', 'room/parent-client.test.mjs', 'scripts/parent-delivery-acceptance.test.mjs', 'experiments/delivery-receipt-repro.mjs', 'experiments/http-utf8-body-repro.mjs']) assert.ok(productRunner.includes(file)) + for (const file of ['skill/scripts/parent-delivery.test.mjs', 'skill/scripts/parent-source-runtime.test.mjs', 'skill/scripts/parent-process.test.mjs', 'skill/scripts/parent-platform-unlinked-executable.test.mjs', 'room/parent-client.test.mjs', 'scripts/parent-delivery-acceptance.test.mjs', 'experiments/delivery-receipt-repro.mjs', 'experiments/http-utf8-body-repro.mjs']) assert.ok(productRunner.includes(file)) assert.match(productRunner, /scriptsWithExtension\(path\.join\(root, directory\), '\.mjs'\)/u) for (const directory of ['room', 'skill/scripts', 'scripts']) for (const file of walk(path.join(root, directory)).filter(file => file.endsWith('.mjs'))) { const source = readFileSync(file, 'utf8') diff --git a/scripts/run-product-ci.mjs b/scripts/run-product-ci.mjs index 3d20b2a6..1afe84f0 100644 --- a/scripts/run-product-ci.mjs +++ b/scripts/run-product-ci.mjs @@ -30,7 +30,7 @@ for (const directory of ['room', 'skill/scripts', 'scripts']) { run(process.execPath, ['--test', 'skill/scripts/runtime-contract.test.mjs']) run(process.execPath, ['experiments/aiterm-unified-delivery-repro.mjs']) run(process.execPath, ['--test', 'scripts/ci-contract.test.mjs', 'scripts/docs-contract.test.mjs']) -run(process.execPath, ['--test', 'skill/scripts/parent-delivery.test.mjs', 'skill/scripts/parent-source-runtime.test.mjs', 'room/parent-client.test.mjs', 'scripts/parent-delivery-acceptance.test.mjs']) +run(process.execPath, ['--test', 'skill/scripts/parent-delivery.test.mjs', 'skill/scripts/parent-source-runtime.test.mjs', 'skill/scripts/parent-process.test.mjs', 'skill/scripts/parent-platform-unlinked-executable.test.mjs', 'room/parent-client.test.mjs', 'scripts/parent-delivery-acceptance.test.mjs']) run(process.execPath, ['experiments/delivery-receipt-repro.mjs']) run(process.execPath, ['experiments/http-utf8-body-repro.mjs']) run(process.execPath, ['experiments/windows-seat-mux-repro.mjs']) diff --git a/skill/scripts/parent-caller.mjs b/skill/scripts/parent-caller.mjs index feb9eca1..575aefbc 100644 --- a/skill/scripts/parent-caller.mjs +++ b/skill/scripts/parent-caller.mjs @@ -57,9 +57,13 @@ export function consumeCaller(harness, request, requestId) { const context = readJson(consumed) if (harness === 'claude') assertClaudeParent(context) const original = { ...args }; delete original.hook_context_id - if (context.harness !== harness || context.name !== request.params.name || context.input_digest !== digest(original) || !sameProcess(context.owner) || Date.now() - context.created_at > 30000) throw failure('PARENT_CALLER_MISMATCH') + verifyHookCaller(context, harness, request.params.name, original) return context } +export function verifyHookCaller(context, harness, name, input) { + if (context.harness !== harness || context.name !== name || context.input_digest !== digest(input) || !sameProcess(context.owner)) throw failure('PARENT_CALLER_MISMATCH') + if (Date.now() - context.created_at > 30000) throw failure('PARENT_BIND_TIMEOUT', '公式hookの本人相関が束縛前に30秒の期限を超えました') +} export function registerEndpoint(spool) { atomicJson(join(parentHome(), 'endpoints', `${spool.id}.json`), { endpoint_id: spool.id, project: realpathSync(spool.project) }) } diff --git a/skill/scripts/parent-connect.mjs b/skill/scripts/parent-connect.mjs index d31282bd..141c16bd 100644 --- a/skill/scripts/parent-connect.mjs +++ b/skill/scripts/parent-connect.mjs @@ -1,5 +1,5 @@ // user領域のMCP/hookはPeertableのentryだけを所有する。外部設定を読む前後で照合する。 -import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, rmSync } from 'node:fs' import { dirname, join } from 'node:path' import { homedir } from 'node:os' import { execFileSync } from 'node:child_process' @@ -12,6 +12,7 @@ export const parentServerName = 'peertable_parent' const hookEntry = fileURLToPath(new URL('./parent-hook.mjs', import.meta.url)) const clientEntry = fileURLToPath(new URL('../../room/client.mjs', import.meta.url)) export const parentRegistration = () => ({ command: process.execPath, args: [clientEntry, 'parent'] }) +export const grokMcpBlock = registration => `[mcp_servers.${parentServerName}]\ncommand = ${JSON.stringify(registration.command)}\nargs = ${JSON.stringify(registration.args)}\n` const pathFor = target => { const home = homedir() if (target === 'claude') return { mcp: join(process.env.CLAUDE_CONFIG_DIR ?? home, '.claude.json'), hooks: join(process.env.CLAUDE_CONFIG_DIR ?? join(home, '.claude'), 'settings.json') } @@ -39,25 +40,37 @@ export function ownsParentConnection(target, { home = parentHome() } = {}) { const commands = Object.values(hookEntries(target)).flatMap(groups => groups.flatMap(group => group.hooks ?? [group])).map(commandIdentity) return record.status === 'registered' && commands.every(command => record.commands.includes(command)) } -export function mergeOwnedHooks(current, additions, ownedCommands) { +export function mergeOwnedHooks(current, additions, ownedCommands, original = { keys: Object.keys(current), event_names: Object.keys(current.hooks ?? {}) }) { const next = { ...current, hooks: { ...(current.hooks ?? {}) } } for (const name of new Set([...Object.keys(next.hooks), ...Object.keys(additions)])) { if (!Array.isArray(next.hooks[name] ?? [])) throw failure('PARENT_HOOK_CONFIG_INVALID') - let inserted = false + let inserted = false, removed = false const groups = [] for (const group of next.hooks[name] ?? []) { const hooks = group.hooks ?? [group] const owns = hooks.some(hook => ownedCommands.includes(commandIdentity(hook))) if (!owns) { groups.push(group); continue } + removed = true const foreign = hooks.filter(hook => !ownedCommands.includes(commandIdentity(hook))) if (foreign.length) groups.push({ ...group, hooks: foreign }) if (!inserted && additions[name]) { groups.push(...additions[name]); inserted = true } } if (!inserted && additions[name]) groups.push(...additions[name]) - next.hooks[name] = groups + if (removed && !groups.length && !original.event_names.includes(name)) delete next.hooks[name] + else next.hooks[name] = groups } + if (!original.keys.includes('hooks') && !Object.keys(next.hooks).length) delete next.hooks return next } +const jsonShape = file => { + const value = existsSync(file) ? readJson(file) : {} + return { existed: existsSync(file), keys: Object.keys(value), event_names: Object.keys(value.hooks ?? {}) } +} +function writeOwnedJson(file, value, original) { + if (!original.existed && !Object.keys(value).length) rmSync(file, { force: true }) + else atomicJson(file, value) + if (existsSync(file) ? digest(readJson(file)) !== digest(value) : Object.keys(value).length !== 0) throw failure('PARENT_CONFIG_READBACK_FAILED') +} // TOMLのliteral/basic quoted keyを解析して自分のtableだけを置換する。 // 他のtableの本文・順序・コメントはbyte単位で残す。 export function tomlHeaderKeys(line) { @@ -119,6 +132,14 @@ function backup(files) { } function writeText(file, text) { mkdirSync(dirname(file), { recursive: true }); const temp = `${file}.${process.pid}.tmp`; writeFileSync(temp, text, { mode: 0o600 }); renameSync(temp, file); if (readFileSync(file, 'utf8') !== text) throw failure('PARENT_CONFIG_READBACK_FAILED') } +export async function removeCodexConfiguration(client, filePath, trust = []) { + // 追加したtableの区切りも公式APIが撤去する。手でheaderだけ消すと追加された空行が残る。 + await client.request('config/batchWrite', { filePath, edits: [ + { keyPath: `mcp_servers.${parentServerName}`, value: null, mergeStrategy: 'replace' }, + ...trust.map(hook => ({ keyPath: `hooks.state.${JSON.stringify(hook.key)}`, value: null, mergeStrategy: 'replace' })), + ] }) +} + export async function connectParent(target, { remove = false } = {}) { if (!['claude', 'codex', 'grok', 'cursor'].includes(target)) throw failure('PARENT_TARGET_INVALID') const paths = pathFor(target), recordPath = join(parentHome(), 'connections', `${target}.json`) @@ -126,11 +147,16 @@ export async function connectParent(target, { remove = false } = {}) { const registration = parentRegistration(), additions = remove ? {} : hookEntries(target) const commands = Object.values(hookEntries(target)).flatMap(groups => groups.flatMap(group => group.hooks ?? [group])).map(commandIdentity) const ownedCommands = [...new Set([...(previous?.commands ?? []), ...commands])] + // 初回の形だけを記録する。利用者の現在値をbackup丸ごとで戻さず、自分が追加した空の容器だけを撤去する。 + const currentShape = { hooks: jsonShape(paths.hooks), ...(['claude', 'cursor'].includes(target) ? { mcp: jsonShape(paths.mcp) } : {}) } + const shape = previous?.status === 'registered' ? previous.json_shape : currentShape + if (!shape) throw failure('PARENT_CONFIG_BASELINE_MISSING', 'この登録には導入前の設定構造の記録がありません') const currentHooks = existsSync(paths.hooks) ? readJson(paths.hooks) : {} const currentOwned = Object.fromEntries(Object.entries(currentHooks.hooks ?? {}).map(([key, groups]) => [key, groups.filter(group => (group.hooks ?? [group]).some(hook => ownedCommands.includes(commandIdentity(hook))))]).filter(([, groups]) => groups.length)) if (previous && digest(currentOwned) !== previous.hooks_digest) throw failure('PARENT_CONFIG_OWNERSHIP_CONFLICT', 'Peertableのhookに未監査の編集があります') - const nextHooks = mergeOwnedHooks(currentHooks, additions, ownedCommands) - if (target === 'cursor') nextHooks.version = currentHooks.version ?? 1 + const nextHooks = mergeOwnedHooks(currentHooks, additions, ownedCommands, shape.hooks) + if (target === 'cursor' && !remove) nextHooks.version = currentHooks.version ?? 1 + if (target === 'cursor' && remove && !shape.hooks.keys.includes('version') && nextHooks.version === 1) delete nextHooks.version const archive = backup([paths.mcp, paths.hooks]) if (target === 'claude' || target === 'cursor') { const current = existsSync(paths.mcp) ? readJson(paths.mcp) : {}, entry = current.mcpServers?.[parentServerName] @@ -138,11 +164,11 @@ export async function connectParent(target, { remove = false } = {}) { const next = { ...current, mcpServers: { ...(current.mcpServers ?? {}) } } if (remove) delete next.mcpServers[parentServerName] else next.mcpServers[parentServerName] = target === 'claude' ? { type: 'stdio', ...registration } : registration - atomicJson(paths.mcp, next) - if (digest(readJson(paths.mcp)) !== digest(next)) throw failure('PARENT_CONFIG_READBACK_FAILED') + if (remove && !shape.mcp.keys.includes('mcpServers') && !Object.keys(next.mcpServers).length) delete next.mcpServers + writeOwnedJson(paths.mcp, next, shape.mcp) } else if (target === 'grok') { const text = existsSync(paths.mcp) ? readFileSync(paths.mcp, 'utf8') : '' - const block = remove ? '' : `\n[mcp_servers.${parentServerName}]\ncommand = ${JSON.stringify(registration.command)}\nargs = ${JSON.stringify(registration.args)}\n` + const block = remove ? '' : grokMcpBlock(registration) writeText(paths.mcp, replaceOwnedToml(text, block, previous?.mcp_digest)) } else { mkdirSync(dirname(paths.mcp), { recursive: true }) @@ -152,20 +178,11 @@ export async function connectParent(target, { remove = false } = {}) { const entry = current.config?.mcp_servers?.[parentServerName] if (entry && previous?.mcp_digest !== digest(entry) && digest(entry) !== digest(registration)) throw failure('PARENT_CONFIG_OWNERSHIP_CONFLICT') if (remove) { - // 自分のMCP tableをTOML構造で解決して解除。literal quoted headerにも対応する。 - let text = readFileSync(paths.mcp, 'utf8'), block = ownedTomlBlock(text).block - text = replaceOwnedToml(text, '', digest(block)) - for (const hook of previous?.trust ?? []) { - const keys = ['hooks', 'state', hook.key] - const owned = ownedTomlBlock(text, keys).block - if (owned) text = replaceOwnedToml(text, '', digest(owned), keys) - } - writeText(paths.mcp, text) + await removeCodexConfiguration(client, paths.mcp, previous?.trust) } else await client.request('config/batchWrite', { filePath: paths.mcp, edits: [{ keyPath: `mcp_servers.${parentServerName}`, value: registration, mergeStrategy: 'replace' }] }) } finally { await client.close() } } - atomicJson(paths.hooks, nextHooks) - if (digest(readJson(paths.hooks)) !== digest(nextHooks)) throw failure('PARENT_CONFIG_READBACK_FAILED') + writeOwnedJson(paths.hooks, nextHooks, shape.hooks) let trust = null if (target === 'codex' && !remove) { const client = await codexConnection(dirname(paths.mcp)) @@ -191,7 +208,7 @@ export async function connectParent(target, { remove = false } = {}) { mcpDigest = digest(entry) } finally { await client.close() } } - const result = { schema: 'peertable.parent-connect.v1', target, status: remove ? 'removed' : 'registered', runtime_status: remove ? 'stopped' : 'PARENT_RESTART_REQUIRED', paths, commands: ownedCommands, hooks_digest: digest(savedOwned), mcp_digest: mcpDigest, backup: archive, trust, updated_at: new Date().toISOString() } + const result = { schema: 'peertable.parent-connect.v1', target, status: remove ? 'removed' : 'registered', runtime_status: remove ? 'stopped' : 'PARENT_RESTART_REQUIRED', paths, commands: ownedCommands, hooks_digest: digest(savedOwned), mcp_digest: mcpDigest, json_shape: shape, backup: archive, trust, updated_at: new Date().toISOString() } atomicJson(recordPath, result) return result } diff --git a/skill/scripts/parent-delivery.test.mjs b/skill/scripts/parent-delivery.test.mjs index 2f979277..3850b12c 100644 --- a/skill/scripts/parent-delivery.test.mjs +++ b/skill/scripts/parent-delivery.test.mjs @@ -9,9 +9,9 @@ import { randomUUID } from 'node:crypto' import { ParentSpool, PAGE_CHARS, renderDelivery, digest, armParentState } from './parent-delivery.mjs' import { processIdentity, processHarness, harnessProcess, readHookEvent, atomicJson } from './parent-platform.mjs' import { queueCodex, codexHook, checkCodexReceiver } from './parent-receivers/codex.mjs' -import { mergeOwnedHooks, tomlHeaderKeys, replaceOwnedToml, ownedTomlBlock, hookEntries, ownsParentConnection } from './parent-connect.mjs' +import { mergeOwnedHooks, tomlHeaderKeys, replaceOwnedToml, ownedTomlBlock, hookEntries, ownsParentConnection, grokMcpBlock } from './parent-connect.mjs' import { cursorEvent } from './parent-receivers/cursor.mjs' -import { clientHarness, hookContext, verifyJoinHook, forgetEndpoint } from './parent-caller.mjs' +import { clientHarness, hookContext, verifyHookCaller, verifyJoinHook, forgetEndpoint } from './parent-caller.mjs' const fixture = t => { const project = mkdtempSync(join(tmpdir(), 'peertable spool 日本語 ')) t.after(() => rmSync(project, { recursive: true, force: true })) @@ -191,6 +191,17 @@ test('Cursor afterMCPExecutionはtool_use_idなしでもendpointと実会話・ assert.equal(verifyJoinHook('cursor', { ...event, mcp_server_name: 'other' }, owner, options), null) }) +test('事前hookの本人相関期限は本人不一致と区別する', () => { + const input = { project: '/専用試験', name: 'bell' } + for (const harness of ['claude', 'cursor', 'grok']) { + const context = { harness, name: 'parent_join', input_digest: digest(input), owner: processIdentity(process.pid), created_at: Date.now() } + assert.doesNotThrow(() => verifyHookCaller(context, harness, 'parent_join', input)) + const expired = { ...context, created_at: Date.now() - 30001 } + assert.throws(() => verifyHookCaller(expired, harness, 'parent_join', input), { code: 'PARENT_BIND_TIMEOUT' }) + assert.throws(() => verifyHookCaller(expired, harness, 'parent_join', { ...input, name: '別人' }), { code: 'PARENT_CALLER_MISMATCH' }) + } +}) + test('probe期限は受信準備から始め、確認済みprobeだけが自身の期限エラーを解除する', t => { const spool = fixture(t) spool.update({ created_at: '2000-01-01T00:00:00.000Z' }) @@ -271,6 +282,27 @@ test('hook更新は外部の順序・承認・入力を保持する', () => { assert.deepEqual(next.permissions, { allow: ['read'] }) }) +test('GrokのMCP登録解除はLF末尾の外部TOMLへ空行を追加しない', () => { + for (const before of ['', '# 利用者の設定\nmodel = "grok"\n', '# 利用者の空行も保持\n\n\n']) { + const added = replaceOwnedToml(before, grokMcpBlock({ command: '/空白 dir/node', args: ['日本語', 'parent'] })) + const removed = replaceOwnedToml(added, '', digest(ownedTomlBlock(added).block)) + assert.equal(removed, before) + } +}) + +test('hook解除は導入した空のevent/containerを撤去し、利用者の空eventと最新の編集を残す', () => { + const pristine = { permissions: { allow: ['read'] } } + const original = { keys: Object.keys(pristine), event_names: [] } + const ours = { hooks: [{ command: '専用hook' }] }, commands = [JSON.stringify(['専用hook', []])] + const registered = mergeOwnedHooks(pristine, { PreToolUse: [ours], Stop: [ours] }, commands, original) + assert.deepEqual(mergeOwnedHooks(registered, {}, commands, original), pristine) + const latest = { ...registered, language: '日本語', hooks: { ...registered.hooks, UserPromptSubmit: [] } } + assert.deepEqual(mergeOwnedHooks(latest, {}, commands, original), { ...pristine, language: '日本語', hooks: { UserPromptSubmit: [] } }) + const originallyEmpty = { hooks: { Stop: [] } }, shape = { keys: ['hooks'], event_names: ['Stop'] } + const added = mergeOwnedHooks(originallyEmpty, { Stop: [ours], PreToolUse: [ours] }, commands, shape) + assert.deepEqual(mergeOwnedHooks(added, {}, commands, shape), originallyEmpty) +}) + test('別hostが正式endpointを置き換えると旧本文を新会話へ移さず旧receiverを閉じる', async t => { const spool = fixture(t); spool.saveEvent(event(1)); const replacement = randomUUID() await assert.rejects(spool.assertCurrentEndpoint({ members: async () => [{ name: 'bell', delivery: { kind: 'parent_receiver', endpoint_id: replacement } }] }), /PARENT_ENDPOINT_SUPERSEDED/u) diff --git a/skill/scripts/parent-platform-unlinked-executable.test.mjs b/skill/scripts/parent-platform-unlinked-executable.test.mjs new file mode 100644 index 00000000..004eda45 --- /dev/null +++ b/skill/scripts/parent-platform-unlinked-executable.test.mjs @@ -0,0 +1,38 @@ +// Linuxの実行中inodeを使うOS境界試験。受信の製品実機証拠には流用しない。 +import test from 'node:test' +import assert from 'node:assert/strict' +import { copyFileSync, mkdtempSync, rmSync, unlinkSync } from 'node:fs' +import { spawn, execFileSync } from 'node:child_process' +import { once } from 'node:events' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { processIdentity, processHarness, sameProcess } from './parent-platform.mjs' +import { codexCallerOptions } from './parent-receivers/codex.mjs' + +test('更新でunlinkされた生存中binaryは本人の分類と実行可能なinodeを保持する', { skip: process.platform !== 'linux' }, async t => { + const dir = mkdtempSync(join(tmpdir(), 'peertable-unlinked-executable-')) + t.after(() => rmSync(dir, { recursive: true, force: true })) + for (const name of ['claude.exe', 'codex']) { + const file = join(dir, name) + copyFileSync(process.execPath, file) + const child = spawn(file, ['-e', 'setTimeout(()=>{},300000)'], { stdio: 'ignore' }) + try { + await once(child, 'spawn') + const before = processIdentity(child.pid) + unlinkSync(file) + const after = processIdentity(child.pid) + assert.equal(sameProcess(after), true) + assert.equal(after.started, before.started) + assert.equal(processHarness(after), name === 'codex' ? 'codex' : 'claude') + assert.equal(after.executable, `/proc/${child.pid}/exe`) + assert.equal(execFileSync(after.executable, ['--version'], { encoding: 'utf8' }).trim(), process.version) + if (name === 'codex') { + // join時の旧pathを保持したcallerからも、同じ実processの更新後identityを取り直す。 + assert.deepEqual(codexCallerOptions({ owner: before, codex_home: dir }), { executable: after.executable, codexHome: dir }) + assert.throws(() => codexCallerOptions({ owner: { ...before, started: '別の開始identity' }, codex_home: dir }), { code: 'PARENT_CODEX_CALLER_EXECUTABLE_UNBOUND' }) + } + } finally { + if (child.exitCode === null && child.signalCode === null) { const ended = once(child, 'exit'); child.kill(); await ended } + } + } +}) diff --git a/skill/scripts/parent-platform.mjs b/skill/scripts/parent-platform.mjs index a84ba910..aad8723b 100644 --- a/skill/scripts/parent-platform.mjs +++ b/skill/scripts/parent-platform.mjs @@ -59,7 +59,13 @@ export function processIdentity(pid, { includeExecutable = true } = {}) { const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ') if (!stat.startsWith(`${pid} (`) || !Number.isSafeInteger(Number(fields[1])) || !/^\d+$/u.test(fields[19] ?? '')) throw failure('PARENT_PROCESS_API_SCHEMA_INVALID') if (fields[0] === 'Z') return null - return result({ pid: Number(pid), parent: Number(fields[1]), started: `${readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim()}:${fields[19]}`, executable: includeExecutable ? readlinkSync(`/proc/${pid}/exe`) : null, command: includeExecutable ? readFileSync(`/proc/${pid}/cmdline`, 'utf8').replaceAll('\0', ' ') : null }) + const executable = includeExecutable ? readlinkSync(`/proc/${pid}/exe`) : null + // 自動更新でunlinkされた実行中binaryは/procの実体を使い、kernelの表示用suffixを名前に混ぜない。 + const deleted = executable?.endsWith(' (deleted)') + return result({ pid: Number(pid), parent: Number(fields[1]), started: `${readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim()}:${fields[19]}`, + executable: deleted ? `/proc/${pid}/exe` : executable, + ...(deleted ? { executable_name: basename(executable.slice(0, -10)) } : {}), + command: includeExecutable ? readFileSync(`/proc/${pid}/cmdline`, 'utf8').replaceAll('\0', ' ') : null }) } const raw = execFileSync('/bin/ps', ['-ww', '-p', String(pid), '-o', 'ppid=', '-o', 'lstart=', '-o', 'command='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() const match = /^(\d+)\s+(\w+\s+\w+\s+\d+\s+\d+:\d+:\d+\s+\d+)\s+(.+)$/u.exec(raw) @@ -95,7 +101,7 @@ export function processDescendsFrom(owner, ancestor) { return false } export function processHarness(identity) { - const executable = basename(identity.executable ?? '').toLowerCase() + const executable = (identity.executable_name ?? basename(identity.executable ?? '')).toLowerCase() const native = { 'claude': 'claude', 'claude.exe': 'claude', 'codex': 'codex', 'codex.exe': 'codex', 'grok': 'grok', 'grok.exe': 'grok', 'cursor-agent': 'cursor', 'cursor.exe': 'cursor', 'cursor': 'cursor' } if (native[executable]) return native[executable] if (/^grok-(?:macos|linux)-(?:aarch64|x86_64)$/u.test(executable)) return 'grok' diff --git a/skill/scripts/parent-process.mjs b/skill/scripts/parent-process.mjs index 5c696264..c5a489ef 100644 --- a/skill/scripts/parent-process.mjs +++ b/skill/scripts/parent-process.mjs @@ -27,8 +27,9 @@ const validPid = value => Number.isSafeInteger(value) && value > 0 export function parseWmiResult(stdout) { let created try { created = JSON.parse(stdout) } catch { throw startFailed(`WMIの応答がJSONではありません: ${String(stdout).slice(0, 200)}`) } - if (!Number.isSafeInteger(created?.return_value) || !validPid(created?.pid)) throw startFailed(`WMIの応答にreturn_value/pidがありません: ${String(stdout).slice(0, 200)}`) + if (!Number.isSafeInteger(created?.return_value)) throw startFailed(`WMIの応答にreturn_valueがありません: ${String(stdout).slice(0, 200)}`) if (created.return_value !== 0) throw startFailed(`Win32_Process.Createが${created.return_value}を返しました`) + if (!validPid(created?.pid)) throw startFailed(`WMIの成功応答にpidがありません: ${String(stdout).slice(0, 200)}`) return created } // bootstrapが書くhandshake(外部processの出力)。pidか、typedな起動失敗のどちらかだけを受ける。 diff --git a/skill/scripts/parent-receivers/codex.mjs b/skill/scripts/parent-receivers/codex.mjs index 9ecd4d2c..03a2680c 100644 --- a/skill/scripts/parent-receivers/codex.mjs +++ b/skill/scripts/parent-receivers/codex.mjs @@ -8,8 +8,9 @@ import { failure, resolveExecutable, atomicJson, processIdentity, sameProcess, r import { digest, renderDelivery } from '../parent-delivery.mjs' export function codexCallerOptions(caller) { - if (!sameProcess(caller.owner) || !['codex', 'codex.exe'].includes(basename(caller.owner.executable ?? '').toLowerCase()) || !caller.codex_home) throw failure('PARENT_CODEX_CALLER_EXECUTABLE_UNBOUND') - return { executable: caller.owner.executable, codexHome: caller.codex_home } + const owner = caller.owner ? processIdentity(caller.owner.pid) : null + if (!owner || owner.started !== caller.owner.started || !['codex', 'codex.exe'].includes((owner.executable_name ?? basename(owner.executable ?? '')).toLowerCase()) || !caller.codex_home) throw failure('PARENT_CODEX_CALLER_EXECUTABLE_UNBOUND') + return { executable: owner.executable, codexHome: caller.codex_home } } export async function codexConnection(project, { executable = resolveExecutable('codex'), codexHome, timeout = 15000 } = {}) { const child = spawn(executable, ['app-server', '--listen', 'stdio://'], { cwd: project, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, ...(codexHome ? { CODEX_HOME: codexHome } : {}) } }) From c9002cdf399b8e945a3377794df1d11f63e35e23 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:32:17 +0900 Subject: [PATCH 35/72] =?UTF-8?q?Grok=E3=81=AE=E5=85=AC=E5=BC=8Ftask?= =?UTF-8?q?=E5=8F=96=E6=B6=88=E3=81=A8=E5=8F=97=E4=BF=A1process=E7=B5=82?= =?UTF-8?q?=E4=BA=86=E3=82=92=E5=85=A8=E9=87=8Freader=E3=81=A7=E7=85=A7?= =?UTF-8?q?=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scenarios-surfaces.mjs | 50 +++++++++++++- .../scenarios-surfaces.test.mjs | 67 +++++++++++++++++++ 2 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 experiments/parent-product-acceptance/scenarios-surfaces.test.mjs diff --git a/experiments/parent-product-acceptance/scenarios-surfaces.mjs b/experiments/parent-product-acceptance/scenarios-surfaces.mjs index 19d02877..853b0142 100644 --- a/experiments/parent-product-acceptance/scenarios-surfaces.mjs +++ b/experiments/parent-product-acceptance/scenarios-surfaces.mjs @@ -4,6 +4,8 @@ import { join } from 'node:path' import { homedir } from 'node:os' import { execFileSync } from 'node:child_process' import { pathToFileURL } from 'node:url' +import { isDeepStrictEqual } from 'node:util' +import { readJsonl } from './harness.mjs' const fail = (code, message) => { throw Object.assign(new Error(message), { code }) } // WindowsはPowerShell 7へargvを渡す。Nodeの既定shellを選ばず、対話stdinは呼出し元のstdioを継承する。 @@ -23,6 +25,29 @@ export function resolveOfficialCli(harness, platform) { return { executable, version } } +// Grokの公式task snapshotと完成済みreader入力を照合する。終了説明の自由文は判定に使わない。 +export function grokControlledTaskEnd(seen, request, { sameProcess }) { + const { id, session, before, operation } = request + const task = seen.tasks.find(item => item.type === 'task_completed' && String(item.task_id) === String(id) && item.order >= before) + if (!task) return null + const snapshot = task.raw_output + if (task.session !== session || snapshot.owner_session_id !== session || snapshot.task_id !== id || snapshot.completed !== true) fail('ACCEPTANCE_GROK_NATIVE_TASK_OWNER', '対象会話の実task完了ではありません') + const reader = seen.toolUses.find(use => use.name === 'get_command_or_subagent_output' && use.session === session && use.order > task.order && isDeepStrictEqual(use.input, { task_ids: [id] }) && use.output?.type === 'TaskOutput' && use.output.Result?.task_id === id) + if (!reader) return null + const result = reader.output.Result + if (result.status !== 'completed' || result.truncated !== false || result.output !== snapshot.output || result.output_file !== snapshot.output_file || result.raw_output_bytes !== snapshot.output_total_bytes || Buffer.byteLength(result.output, 'utf8') !== snapshot.output_total_bytes) fail('ACCEPTANCE_GROK_NATIVE_FULL_READ', '実task snapshotと公式全量readerが一致しません') + let control = null + if (operation === 'cancel') { + control = seen.toolUses.find(use => use.name === 'kill_task' && use.session === session && use.order >= before && use.order < task.order && isDeepStrictEqual(use.input, { task_id: id }) && use.output && use.output_order > use.order) + if (!control || snapshot.explicitly_killed !== true) fail('ACCEPTANCE_GROK_CANCEL_NOT_OBSERVED', '完成済みkill_taskと対象taskの明示終了を確認できません') + } else if (operation === 'exit') { + if (snapshot.explicitly_killed !== false || !request.receiver_owner || sameProcess(request.receiver_owner) || snapshot.signal == null && (!Number.isInteger(snapshot.exit_code) || snapshot.exit_code === 0)) fail('ACCEPTANCE_GROK_EXIT_NOT_OBSERVED', '専用受信process消失と非cancelの実task異常終了を確認できません') + control = request.process_fault + } else fail('ACCEPTANCE_NATIVE_TASK_CONTROL_UNCONFIRMED', `Grok/${operation}`) + const products = result.output.split('\n').filter(line => line.startsWith('{')).flatMap(line => { try { const value = JSON.parse(line); return value.schema === 'peertable.parent-background-result.v1' ? [value] : [] } catch { return [] } }) + return { id, session, finished: true, outcome: operation, delivered: products.some(value => value.outcome === 'ready'), output: result.output, artifact: result.output_file, exit_code: snapshot.exit_code, signal: snapshot.signal, task_snapshot: snapshot, control, reader_tool_use_id: reader.id, official_reader: reader } +} + export async function createBackgroundSurfaceAdapters({ pkg, tokenFile, backgroundObserverFactory }) { if (!backgroundObserverFactory) { try { backgroundObserverFactory = (await import('./background-harness.mjs')).createBackgroundHarnessObserver } @@ -34,7 +59,7 @@ export async function createBackgroundSurfaceAdapters({ pkg, tokenFile, backgrou const adapters = {} for (const harness of ['cursor', 'grok']) adapters[harness] = { bind(fixture) { - const hookFile = join(fixture.directory, 'product-hook-events.jsonl'), observers = new Map() + const hookFile = join(fixture.directory, 'product-hook-events.jsonl'), observers = new Map(), taskRequests = new Map() const transcriptPath = session => harness === 'cursor' ? join(homedir(), '.cursor/projects', fixture.project.replace(/[^a-zA-Z0-9]+/gu, '-').replace(/^-+/u, ''), 'agent-transcripts', session, `${session}.jsonl`) : join(homedir(), '.grok/sessions', encodeURIComponent(fixture.project), session, 'updates.jsonl') const observeSession = session => { const file = transcriptPath(session) @@ -61,7 +86,9 @@ export async function createBackgroundSurfaceAdapters({ pkg, tokenFile, backgrou taskController: { timeoutWindowMs: 86520000, async read(id, { session }) { - const records = observeSession(session).tasks.filter(task => String(task.task_id ?? task.id) === String(id)) + const seen = observeSession(session), controlled = taskRequests.get(String(id)) + if (harness === 'grok' && controlled && controlled.operation !== 'timeout') return grokControlledTaskEnd(seen, controlled, { sameProcess: platform.sameProcess }) ?? { id: String(id), session, finished: false, outcome: null } + const records = seen.tasks.filter(task => String(task.task_id ?? task.id) === String(id)) const read = records.find(task => task.reader_tool_use_id && task.result?.schema === 'peertable.parent-background-result.v1') if (!read) return { id: String(id), session, finished: false, outcome: null } const completed = records.find(task => task.raw_output?.completed === true) @@ -71,6 +98,23 @@ export async function createBackgroundSurfaceAdapters({ pkg, tokenFile, backgrou }, async request(operation, { id, session }) { if (operation === 'timeout') return { operation: '通常製品leaseの実期限まで待機', id: String(id), session, timeout_shortened: false } + if (harness === 'grok' && ['cancel', 'exit'].includes(operation)) { + const target = fixture.target, current = target.spool.read(), parent = target.meta.parent_process, waiter = current.waiter, task = waiter?.native_task + const identity = (a, b) => a && b && a.pid === b.pid && a.started === b.started + if (target.spool.project !== fixture.project || current.endpoint_id !== target.spool.id || current.caller.conversation !== session || !identity(current.caller.owner, parent) || !platform.sameProcess(parent) || task?.id !== id || !platform.sameProcess(task.process_identity) || !platform.sameProcess(waiter.owner) || !platform.processDescendsFrom(waiter.owner, task.process_identity)) fail('ACCEPTANCE_NATIVE_TASK_OWNER', '専用fixtureの公式task/受信process相関がありません') + const request = { operation, id, session, before: observeSession(session).rows } + taskRequests.set(String(id), request) + if (operation === 'cancel') await fixture.submit(`専用背景taskの取消を実測します。kill_taskへ完成済み入力${JSON.stringify({ task_id: id })}を渡し、完了後get_command_or_subagent_outputへ完成済み入力${JSON.stringify({ task_ids: [id] })}を渡して全量を読み、終了結果を報告してください。timeout_msを追加せず、他のtaskを操作せず、parent_joinはまだ呼ばないでください。`) + else { + const { endpointsFor } = await import(pathToFileURL(join(pkg, 'skill/scripts/parent-caller.mjs')).href) + if (endpointsFor().some(endpoint => endpoint.id !== target.spool.id && endpoint.read().runtime !== 'stopped' && identity(endpoint.read().waiter?.owner, waiter.owner))) fail('ACCEPTANCE_PROCESS_FAULT_SHARED', '他endpoint共有の受信processを終了しません') + request.receiver_owner = waiter.owner + request.process_fault = { operation: 'own受信processへのSIGTERM', owner: waiter.owner, endpoint_id: target.spool.id, parent_session: session, at: new Date().toISOString() } + process.kill(waiter.owner.pid, 'SIGTERM') + await fixture.submit(`専用背景task ${id} の終了を実測します。task_completed後にget_command_or_subagent_outputへ完成済み入力${JSON.stringify({ task_ids: [id] })}を渡して全量を読み、終了結果を報告してください。timeout_msを追加せず、taskをkillせず、parent_joinはまだ呼ばないでください。`) + } + return request + } fail('ACCEPTANCE_NATIVE_TASK_CONTROL_UNCONFIRMED', `${harness}/${operation}: 公式cancel/exit操作は未実測です`) }, }, @@ -91,7 +135,7 @@ export async function createBackgroundSurfaceAdapters({ pkg, tokenFile, backgrou fixture.modify(join(fixture.project, '.grok/hooks/peertable-parent.json'), () => Buffer.from(JSON.stringify({ hooks: owned }) + '\n')) } }, - hookEvents: () => existsSync(hookFile) ? readFileSync(hookFile, 'utf8').split('\n').filter(Boolean).map(line => JSON.parse(line)) : [], + hookEvents: () => existsSync(hookFile) ? readJsonl(readFileSync(hookFile, 'utf8'), hookFile).rows.map(item => item.row) : [], hookFile, } }, diff --git a/experiments/parent-product-acceptance/scenarios-surfaces.test.mjs b/experiments/parent-product-acceptance/scenarios-surfaces.test.mjs new file mode 100644 index 00000000..c300f228 --- /dev/null +++ b/experiments/parent-product-acceptance/scenarios-surfaces.test.mjs @@ -0,0 +1,67 @@ +// 公式snapshot/readerの判定とOS起動だけをfocusedで確認する。実機合格の代用にはしない。 +import test from 'node:test' +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { nativeInvocation, grokControlledTaskEnd } from './scenarios-surfaces.mjs' +import { shellCommand } from '../../skill/scripts/parent-platform.mjs' + +const fixture = () => { + const output = '受信processの終了\n', id = 'own-task', session = 'own-session' + const snapshot = { task_id: id, owner_session_id: session, completed: true, explicitly_killed: true, output, output_file: '/own/output', output_total_bytes: Buffer.byteLength(output), exit_code: null, signal: 15 } + const seen = { tasks: [{ type: 'task_completed', task_id: id, session, order: 12, raw_output: snapshot }], toolUses: [ + { id: 'cancel-use', name: 'kill_task', input: { task_id: id }, output: { 公式結果: '原値を保持' }, output_order: 13, order: 10, session }, + { id: 'reader-use', name: 'get_command_or_subagent_output', input: { task_ids: [id] }, order: 14, session, output: { type: 'TaskOutput', Result: { task_id: id, status: 'completed', truncated: false, output, output_file: snapshot.output_file, raw_output_bytes: snapshot.output_total_bytes } } }, + ] } + return { seen, request: { operation: 'cancel', id, session, before: 10 } } +} + +test('Grok cancelは同taskの公式kill・明示取消snapshot・全量readerを全て照合する', () => { + const { seen, request } = fixture(), result = grokControlledTaskEnd(seen, request, { sameProcess: () => false }) + assert.equal(result.outcome, 'cancel'); assert.equal(result.control.id, 'cancel-use'); assert.equal(result.reader_tool_use_id, 'reader-use'); assert.equal(result.output, seen.tasks[0].raw_output.output) + seen.tasks[0].raw_output.owner_session_id = '他会話' + assert.throws(() => grokControlledTaskEnd(seen, request, { sameProcess: () => false }), { code: 'ACCEPTANCE_GROK_NATIVE_TASK_OWNER' }) +}) + +test('既に終了済みtaskや別task取消・短縮readerはcancel成功にしない', () => { + for (const mutate of [seen => { seen.tasks[0].raw_output.explicitly_killed = false }, seen => { seen.toolUses[0].input.task_id = '別task' }]) { + const { seen, request } = fixture(); mutate(seen) + assert.throws(() => grokControlledTaskEnd(seen, request, { sameProcess: () => false }), { code: 'ACCEPTANCE_GROK_CANCEL_NOT_OBSERVED' }) + } + const { seen, request } = fixture(); seen.toolUses[1].input.timeout_ms = 1 + assert.equal(grokControlledTaskEnd(seen, request, { sameProcess: () => false }), null) + delete seen.toolUses[1].input.timeout_ms; seen.toolUses[1].output.Result.output = '切詰め' + assert.throws(() => grokControlledTaskEnd(seen, request, { sameProcess: () => false }), { code: 'ACCEPTANCE_GROK_NATIVE_FULL_READ' }) +}) + +test('Grok exitは取消と区別してown受信process消失と異常task終了を要求する', () => { + const { seen, request } = fixture(); request.operation = 'exit'; request.receiver_owner = { pid: 123, started: 'own-start' }; request.process_fault = { operation: 'SIGTERM', owner: request.receiver_owner }; seen.tasks[0].raw_output.explicitly_killed = false + assert.equal(grokControlledTaskEnd(seen, request, { sameProcess: () => false }).outcome, 'exit') + assert.throws(() => grokControlledTaskEnd(seen, request, { sameProcess: () => true }), { code: 'ACCEPTANCE_GROK_EXIT_NOT_OBSERVED' }) + seen.tasks[0].raw_output.signal = null; seen.tasks[0].raw_output.exit_code = 0 + assert.throws(() => grokControlledTaskEnd(seen, request, { sameProcess: () => false }), { code: 'ACCEPTANCE_GROK_EXIT_NOT_OBSERVED' }) +}) + +test('取消後のready本文を検出して本文配送との混同を拒否できる', () => { + const { seen, request } = fixture(), output = JSON.stringify({ schema: 'peertable.parent-background-result.v1', outcome: 'ready' }) + '\n' + Object.assign(seen.tasks[0].raw_output, { output, output_total_bytes: Buffer.byteLength(output) }) + Object.assign(seen.toolUses[1].output.Result, { output, raw_output_bytes: Buffer.byteLength(output) }) + assert.equal(grokControlledTaskEnd(seen, request, { sameProcess: () => false }).delivered, true) +}) + +test('Windows起動はPowerShell7 EncodedCommandへspace/quote argvを閉じ、対話stdinを妨げない', () => { + const executable = "C:\\Program Files\\親'入口.ps1", argv = ['空白 引数', "quote'\"", '$env:HOME', '`literal'] + const invocation = nativeInvocation(executable, argv, { platformName: 'win32', shellCommand, interactive: true }) + assert.equal(invocation.executable, 'pwsh.exe'); assert.ok(!invocation.argv.includes('-NonInteractive')) + const script = Buffer.from(invocation.argv.at(-1), 'base64').toString('utf16le') + assert.ok(script.includes(shellCommand(executable, argv, 'win32'))) + assert.ok(nativeInvocation(executable, argv, { platformName: 'win32', shellCommand }).argv.includes('-NonInteractive')) +}) + +test('POSIX起動はargv/env/cwd/stdinを実childへそのまま渡す', async () => { + const args = ['空白 引数', "quote'\"", '$HOME', '`literal'] + const invocation = nativeInvocation(process.execPath, ['-e', "let input='';for await(const part of process.stdin)input+=part;console.log(JSON.stringify({args:process.argv.slice(1),cwd:process.cwd(),value:process.env.PEERTABLE_FIXTURE_LITERAL,input}));", ...args], { platformName: 'darwin', shellCommand, interactive: true }) + const cwd = process.cwd(), child = spawn(invocation.executable, invocation.argv, { cwd, env: { ...process.env, PEERTABLE_FIXTURE_LITERAL: "値 $HOME ' \"" }, stdio: ['pipe', 'pipe', 'inherit'] }) + let output = ''; child.stdout.on('data', part => { output += part }); child.stdin.end('日本語 stdin\n') + const code = await new Promise((resolve, reject) => { child.once('error', reject); child.once('exit', resolve) }) + assert.equal(code, 0); assert.deepEqual(JSON.parse(output), { args, cwd, value: "値 $HOME ' \"", input: '日本語 stdin\n' }) +}) From c1a9885d90030a0493e410c8df632778f1af7b83 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:38:47 +0900 Subject: [PATCH 36/72] =?UTF-8?q?=E8=A6=AA=E7=B5=82=E4=BA=86=E5=BE=8C?= =?UTF-8?q?=E3=81=AE=E7=A7=81=E7=89=A9receiver=E5=9B=9E=E5=8F=8E=E3=81=A8o?= =?UTF-8?q?bserver=E8=B5=B7=E5=8B=95identity=E3=82=92=E7=85=A7=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scenarios-context.mjs | 4 +- .../scenarios-fixtures.mjs | 7 +++- .../scenarios-fixtures.test.mjs | 23 ++++++++++- .../scenarios-native.mjs | 16 ++++++-- .../scenarios-native.test.mjs | 40 +++++++++++++++++-- .../scenarios-surfaces.mjs | 10 ++++- .../scenarios-surfaces.test.mjs | 30 +++++++++++++- 7 files changed, 116 insertions(+), 14 deletions(-) diff --git a/experiments/parent-product-acceptance/scenarios-context.mjs b/experiments/parent-product-acceptance/scenarios-context.mjs index 05bd205a..343b23a6 100644 --- a/experiments/parent-product-acceptance/scenarios-context.mjs +++ b/experiments/parent-product-acceptance/scenarios-context.mjs @@ -297,7 +297,9 @@ export async function createScenarioContext(options) { if (!nativeStopFile) fail('ACCEPTANCE_IDLE_NATIVE_END_MISSING', 'Cursorの専用stop hook保存先がありません') const events = existsSync(nativeStopFile) ? readJsonl(readFileSync(nativeStopFile, 'utf8'), nativeStopFile).rows.map(item => item.row) : [] const completed = cursorIdleCompletion(events, last, meta.parent_session) - if (!completed || sameProcess(processIdentity(completed.pid))) return null + if (!completed) return null + if (!completed.owner?.started || completed.owner.pid !== completed.pid) fail('ACCEPTANCE_IDLE_OBSERVER_IDENTITY_MISSING', 'stop observerの起動時identityがありません') + if (sameProcess(completed.owner)) return null faultState.set(`${scope.runId}:idle_stop`, completed) return current.replies.length === count && current.replies.at(-1)?.turn_id === last.turn_id && /Add a follow-up/u.test(view) } diff --git a/experiments/parent-product-acceptance/scenarios-fixtures.mjs b/experiments/parent-product-acceptance/scenarios-fixtures.mjs index a4e5f6de..34b3bb52 100644 --- a/experiments/parent-product-acceptance/scenarios-fixtures.mjs +++ b/experiments/parent-product-acceptance/scenarios-fixtures.mjs @@ -17,6 +17,11 @@ const until = async (label, probe, ms = 180000, interval = 250) => { const end = const contained = (root, file) => { const path = relative(realpathSync(root), file); return path !== '' && !path.startsWith('..') && !isAbsolute(path) } const writeJson = (file, value) => { mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); writeFileSync(file, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 }) } +// observer本人が起動時identityを保存する。終了後にPIDだけから本人を取り直さない。 +export function nativeHookObserverSource(platformEntry) { + return `import {processIdentity} from ${JSON.stringify(platformEntry)};import {readFileSync,appendFileSync,existsSync} from 'node:fs';const owner=processIdentity(process.pid);let raw='';for await(const chunk of process.stdin)raw+=chunk;const event=JSON.parse(raw.replace(/^\\uFEFF/u,''));appendFileSync(process.argv[2],JSON.stringify({at:new Date().toISOString(),pid:process.pid,owner,event})+'\\n');if(existsSync(process.argv[3])){const control=JSON.parse(readFileSync(process.argv[3],'utf8'));if(control.hold){while(!existsSync(control.release))await new Promise(r=>setTimeout(r,25));}}process.stdout.write('{}\\n');\n` +} + export function hookConfigurationSnapshot(hooks) { const beforeBytes = existsSync(hooks) ? readFileSync(hooks) : null const beforeHooks = beforeBytes ? JSON.parse(beforeBytes.toString('utf8').replace(/^\uFEFF/u, '')) : null @@ -179,7 +184,7 @@ export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUr } fixture.addObserver = async ({ events, additionalArgs = [] } = {}) => { const observer = join(project, 'acceptance-hook.mjs'), observations = join(directory, 'official-hook-events.jsonl'), controls = join(directory, 'hook-controls.json') - writeFileSync(observer, `import {readFileSync,appendFileSync,existsSync} from 'node:fs';let raw='';for await(const chunk of process.stdin)raw+=chunk;const event=JSON.parse(raw.replace(/^\\uFEFF/u,''));appendFileSync(process.argv[2],JSON.stringify({at:new Date().toISOString(),pid:process.pid,event})+'\\n');if(existsSync(process.argv[3])){const control=JSON.parse(readFileSync(process.argv[3],'utf8'));if(control.hold){while(!existsSync(control.release))await new Promise(r=>setTimeout(r,25));}}process.stdout.write('{}\\n');\n`, { mode: 0o600 }) + writeFileSync(observer, nativeHookObserverSource(pathToFileURL(join(pkg, 'skill/scripts/parent-platform.mjs')).href), { mode: 0o600 }) const command = platform.hookCommand(process.execPath, [observer, observations, controls, ...additionalArgs]) if (!['claude', 'codex'].includes(harness) && !adapter.projectHookFile) fail('ACCEPTANCE_PROJECT_HOOK_PATH_UNCONFIRMED', `${harness}: 公式のproject hook配置が実測されていません`) const file = harness === 'claude' ? join(project, '.claude/settings.local.json') : harness === 'codex' ? join(project, '.codex/hooks.json') : adapter.projectHookFile(project) diff --git a/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs b/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs index 11aff087..3fa5fdf1 100644 --- a/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs +++ b/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs @@ -5,7 +5,9 @@ import { spawn } from 'node:child_process' import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { assertOnlyProjectTrustChanged, hookConfigurationSnapshot, waitOwnedFixtureExit } from './scenarios-fixtures.mjs' +import { pathToFileURL } from 'node:url' +import { processIdentity, sameProcess } from '../../skill/scripts/parent-platform.mjs' +import { assertOnlyProjectTrustChanged, hookConfigurationSnapshot, waitOwnedFixtureExit, nativeHookObserverSource } from './scenarios-fixtures.mjs' test('fixture終了は実child消失の後も製品自己停止と索引撤去を待つ', async t => { const child = spawn(process.execPath, ['-e', 'setTimeout(()=>process.exit(0),150)'], { stdio: 'ignore' }) @@ -51,3 +53,22 @@ test('専用project trustの1 entryだけを許し、他設定の意味差と既 assertOnlyProjectTrustChanged({}, { projects: { [project]: { trust_level: 'trusted' } } }, project) assertOnlyProjectTrustChanged({ projects: { [project]: { trust_level: 'trusted' } } }, {}, project, { remove: true }) }) + + +test('公式hook observerは起動時PID/startを保存し、解除後の本人消失を照合する', async t => { + const directory = mkdtempSync(join(tmpdir(), 'peertable-observer-identity-')); t.after(() => rmSync(directory, { recursive: true, force: true })) + const script = join(directory, 'observer.mjs'), observed = join(directory, 'events.jsonl'), controls = join(directory, 'controls.json'), release = join(directory, 'release.json') + writeFileSync(script, nativeHookObserverSource(pathToFileURL(join(process.cwd(), 'skill/scripts/parent-platform.mjs')).href)) + writeFileSync(controls, JSON.stringify({ hold: true, release })) + const child = spawn(process.execPath, [script, observed, controls], { stdio: ['pipe', 'pipe', 'inherit'] }) + t.after(() => { if (child.exitCode === null) child.kill('SIGKILL') }) + const ended = new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) }) + const event = { hook_event_name: 'stop', status: 'completed', conversation_id: 'own', generation_id: 'turn', text: '字面 & 日本語' } + child.stdin.end(JSON.stringify(event)) + let row = null + const deadline = Date.now() + 3000 + while (!row) { try { row = JSON.parse(readFileSync(observed, 'utf8').trim()) } catch (error) { if (error.code !== 'ENOENT') throw error }; assert.ok(Date.now() < deadline); if (!row) await new Promise(resolve => setTimeout(resolve, 20)) } + assert.deepEqual(row.event, event); assert.equal(row.pid, child.pid); assert.equal(row.owner.pid, child.pid); assert.equal(row.owner.started, processIdentity(child.pid).started) + assert.equal(sameProcess(row.owner), true); assert.equal(sameProcess({ ...row.owner, started: '同PIDの別起動' }), false) + writeFileSync(release, '{}'); assert.equal(await ended, 0); assert.equal(sameProcess(row.owner), false) +}) diff --git a/experiments/parent-product-acceptance/scenarios-native.mjs b/experiments/parent-product-acceptance/scenarios-native.mjs index 05992773..d71d3930 100644 --- a/experiments/parent-product-acceptance/scenarios-native.mjs +++ b/experiments/parent-product-acceptance/scenarios-native.mjs @@ -119,19 +119,27 @@ export function assertOwnedReceiver(owner, { target, fixture, sameProcess, proce return { endpoint_id: target.spool.id, project: fixture.project, parent_session: target.meta.parent_session, caller_owner: current.caller.owner, receiver_role: role, receiver_owner: owner } } +// 再開は停止時の所有証拠を使う。native親の終了でown receiverの回収を妨げない。 +export function assertOwnedResume(owner, { target, fixture, held, sameProcess, endpoints }) { + const proof = held?.proof, sameIdentity = (a, b) => a && b && a.pid === b.pid && a.started === b.started + if (!proof || held.target !== target || held.fixture !== fixture || target.fixture !== fixture || proof.project !== fixture.project || proof.endpoint_id !== target.spool.id || target.spool.project !== fixture.project || proof.parent_session !== target.meta.parent_session || !sameIdentity(proof.caller_owner, target.meta.parent_process) || !sameIdentity(proof.receiver_owner, owner) || !sameIdentity(held.owner, owner) || !sameProcess(owner)) fail('ACCEPTANCE_PROCESS_RESUME_OWNER', '停止時の専用fixture/受信identityと一致しません') + if (endpoints.some(endpoint => endpoint.id !== proof.endpoint_id && endpoint.read().runtime !== 'stopped' && [endpoint.read().watcher, endpoint.read().waiter?.owner].some(other => sameIdentity(other, owner)))) fail('ACCEPTANCE_PROCESS_FAULT_SHARED', '他endpointと共有する受信processは操作しません') + return proof +} + // OS適合はここだけ。公式spoolのPID+開始identityを操作直前に照合し、任意PIDへ作用しない。 export async function pauseOwnedReceiver(owner, { target, fixture, sameProcess, processDescendsFrom, paused, resume = false }) { const { endpointsFor } = await import(pathToFileURL(join(fixture.pkg, 'skill/scripts/parent-caller.mjs')).href) - const proof = assertOwnedReceiver(owner, { target, fixture, sameProcess, processDescendsFrom, endpoints: endpointsFor() }) + const endpoints = endpointsFor(), proof = resume ? assertOwnedResume(owner, { target, fixture, held: paused.get(owner.pid), sameProcess, endpoints }) : assertOwnedReceiver(owner, { target, fixture, sameProcess, processDescendsFrom, endpoints }) if (process.platform !== 'win32') process.kill(owner.pid, resume ? 'SIGCONT' : 'SIGSTOP') else { // Windows公式thread API。列挙するのは検証済みown PIDのthreadだけ。 const script = `Add-Type -TypeDefinition 'using System;using System.Runtime.InteropServices;public class PeertableOwnThreads{[DllImport("kernel32.dll")]public static extern IntPtr OpenThread(uint a,bool b,uint c);[DllImport("kernel32.dll")]public static extern uint SuspendThread(IntPtr h);[DllImport("kernel32.dll")]public static extern uint ResumeThread(IntPtr h);[DllImport("kernel32.dll")]public static extern bool CloseHandle(IntPtr h);}';$p=Get-Process -Id ${owner.pid};foreach($t in $p.Threads){$h=[PeertableOwnThreads]::OpenThread(2,$false,$t.Id);if($h -eq [IntPtr]::Zero){throw 'own thread open failed'};try{$r=[PeertableOwnThreads]::${resume ? 'ResumeThread' : 'SuspendThread'}($h);if($r -eq 4294967295){throw 'own thread control failed'}}finally{[void][PeertableOwnThreads]::CloseHandle($h)}}` execFileSync('pwsh.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { stdio: 'pipe' }) } - if (resume) paused.delete(owner.pid); else paused.set(owner.pid, { owner, target, fixture }) - if (!sameProcess(owner)) fail('ACCEPTANCE_PROCESS_FAULT_DISAPPEARED', '一時停止/再開の対象が終了しました') - return { ...proof, operation: resume ? 'resume' : 'suspend', at: new Date().toISOString() } + if (resume) paused.delete(owner.pid); else paused.set(owner.pid, { owner: structuredClone(owner), target, fixture, proof: structuredClone(proof) }) + if (!resume && !sameProcess(owner)) fail('ACCEPTANCE_PROCESS_FAULT_DISAPPEARED', '一時停止の対象が終了しました') + return { ...proof, operation: resume ? 'resume' : 'suspend', receiver_alive_after: Boolean(sameProcess(owner)), at: new Date().toISOString() } } // 残る境界を呼出し側の任意callbackへ押し出さず、このmodule内の公式操作に固定する。 diff --git a/experiments/parent-product-acceptance/scenarios-native.test.mjs b/experiments/parent-product-acceptance/scenarios-native.test.mjs index 321bc343..11b8127c 100644 --- a/experiments/parent-product-acceptance/scenarios-native.test.mjs +++ b/experiments/parent-product-acceptance/scenarios-native.test.mjs @@ -1,13 +1,13 @@ // 追加adapterの誤相関をfocusedで確認する。fixtureの合格をproduct_liveへ流用しない。 import test from 'node:test' import assert from 'node:assert/strict' -import { spawn } from 'node:child_process' +import { spawn, execFileSync } from 'node:child_process' import { createInterface } from 'node:readline' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { competitorProof, queueConnectionProof, monitorQueueConnections, createNativeActions, productToolError, assertOwnedReceiver } from './scenarios-native.mjs' -import { processIdentity, sameProcess } from '../../skill/scripts/parent-platform.mjs' +import { competitorProof, queueConnectionProof, monitorQueueConnections, createNativeActions, productToolError, assertOwnedReceiver, assertOwnedResume, pauseOwnedReceiver } from './scenarios-native.mjs' +import { processIdentity, sameProcess, processDescendsFrom } from '../../skill/scripts/parent-platform.mjs' import { scenarioPlan } from './scenarios.mjs' const event = { session_id: 'own', turn_id: 'turn', hook_event_name: 'PostToolUse', tool_use_id: 'tool' } @@ -91,3 +91,37 @@ test('独立watcherはown spoolとcaller本人で確認し、他endpoint共有 state.caller.conversation = 'own会話'; state.watcher = null; state.waiter = { owner } assert.throws(() => assertOwnedReceiver(owner, options), { code: 'ACCEPTANCE_PROCESS_FAULT_OWNER' }) }) + + +test('停止時の証拠でown receiverを再開し、終了した親と再利用PIDを区別する', () => { + const parent = { pid: 100, started: '親開始' }, owner = { pid: 200, started: 'watcher開始' } + const fixture = { project: '/専用project', harness: 'codex' } + const current = { endpoint_id: 'own', harness: 'codex', caller: { harness: 'codex', conversation: 'own会話', owner: parent }, watcher: owner } + const target = { fixture, meta: { parent_process: parent, parent_session: 'own会話' }, spool: { project: fixture.project, id: 'own', read: () => current } } + const proof = assertOwnedReceiver(owner, { target, fixture, sameProcess: () => true, processDescendsFrom: () => false, endpoints: [] }) + const held = { target, fixture, owner: structuredClone(owner), proof: structuredClone(proof) } + const sameProcess = value => value.pid === owner.pid && value.started === owner.started + current.watcher = null; current.runtime = 'stopped' + assert.deepEqual(assertOwnedResume(owner, { target, fixture, held, sameProcess, endpoints: [] }), proof) + assert.throws(() => assertOwnedResume({ ...owner, started: '再利用PID' }, { target, fixture, held, sameProcess, endpoints: [] }), { code: 'ACCEPTANCE_PROCESS_RESUME_OWNER' }) + assert.throws(() => assertOwnedResume(owner, { target: { ...target }, fixture, held, sameProcess, endpoints: [] }), { code: 'ACCEPTANCE_PROCESS_RESUME_OWNER' }) +}) + + +test('実processで独立receiverを停止し、親が先に終了しても再開して回収する', { skip: process.platform === 'win32' }, async t => { + // 実CLI/製品配送の代用ではなく、own子processに対する停止・再開境界だけを再現する。 + const script = 'process.stdin.resume();process.stdin.on("end",()=>process.exit(0))' + const parentChild = spawn(process.execPath, ['-e', script], { stdio: ['pipe', 'ignore', 'inherit'] }), receiver = spawn(process.execPath, ['-e', script], { stdio: ['pipe', 'ignore', 'inherit'] }) + t.after(() => { if (parentChild.exitCode === null) parentChild.kill('SIGKILL'); if (receiver.exitCode === null) { receiver.kill('SIGCONT'); receiver.kill('SIGKILL') } }) + const parent = processIdentity(parentChild.pid), owner = processIdentity(receiver.pid) + const fixture = { project: '/focused独立receiver', harness: 'codex', pkg: process.cwd() }, current = { endpoint_id: 'own', harness: 'codex', caller: { harness: 'codex', conversation: 'own会話', owner: parent }, watcher: owner } + const target = { fixture, meta: { parent_process: parent, parent_session: 'own会話' }, spool: { project: fixture.project, id: 'own', read: () => current } }, paused = new Map() + const options = { target, fixture, sameProcess, processDescendsFrom, paused } + assert.equal((await pauseOwnedReceiver(owner, options)).operation, 'suspend') + assert.match(execFileSync('/bin/ps', ['-p', String(owner.pid), '-o', 'state='], { encoding: 'utf8' }), /T/u) + const parentGone = new Promise(resolve => parentChild.once('exit', resolve)); parentChild.stdin.end(); await parentGone + assert.equal(sameProcess(parent), false) + assert.equal((await pauseOwnedReceiver(owner, { ...options, resume: true })).operation, 'resume'); assert.equal(paused.size, 0) + const receiverGone = new Promise(resolve => receiver.once('exit', resolve)); receiver.stdin.end(); await receiverGone + assert.equal(sameProcess(owner), false) +}) diff --git a/experiments/parent-product-acceptance/scenarios-surfaces.mjs b/experiments/parent-product-acceptance/scenarios-surfaces.mjs index 853b0142..e1ac648c 100644 --- a/experiments/parent-product-acceptance/scenarios-surfaces.mjs +++ b/experiments/parent-product-acceptance/scenarios-surfaces.mjs @@ -5,9 +5,15 @@ import { homedir } from 'node:os' import { execFileSync } from 'node:child_process' import { pathToFileURL } from 'node:url' import { isDeepStrictEqual } from 'node:util' -import { readJsonl } from './harness.mjs' const fail = (code, message) => { throw Object.assign(new Error(message), { code }) } +// hook専用の確定行だけを読む。harnessの起動adapterへ逆依存せず、未確定末尾を保留する。 +const readOwnHookEvents = file => { + if (!existsSync(file)) return [] + const lines = readFileSync(file, 'utf8').split('\n'); lines.pop() + return lines.map((line, index) => { try { return JSON.parse(line) } catch { fail('ACCEPTANCE_HOOK_EVENT_CORRUPT', `${file}:${index + 1}の確定hook行がJSONではありません`) } }) +} + // WindowsはPowerShell 7へargvを渡す。Nodeの既定shellを選ばず、対話stdinは呼出し元のstdioを継承する。 export function nativeInvocation(executable, args, { platformName = process.platform, shellCommand, interactive = false } = {}) { if (platformName !== 'win32') return { executable, argv: args } @@ -135,7 +141,7 @@ export async function createBackgroundSurfaceAdapters({ pkg, tokenFile, backgrou fixture.modify(join(fixture.project, '.grok/hooks/peertable-parent.json'), () => Buffer.from(JSON.stringify({ hooks: owned }) + '\n')) } }, - hookEvents: () => existsSync(hookFile) ? readJsonl(readFileSync(hookFile, 'utf8'), hookFile).rows.map(item => item.row) : [], + hookEvents: () => readOwnHookEvents(hookFile), hookFile, } }, diff --git a/experiments/parent-product-acceptance/scenarios-surfaces.test.mjs b/experiments/parent-product-acceptance/scenarios-surfaces.test.mjs index c300f228..ee56dcfb 100644 --- a/experiments/parent-product-acceptance/scenarios-surfaces.test.mjs +++ b/experiments/parent-product-acceptance/scenarios-surfaces.test.mjs @@ -2,8 +2,12 @@ import test from 'node:test' import assert from 'node:assert/strict' import { spawn } from 'node:child_process' -import { nativeInvocation, grokControlledTaskEnd } from './scenarios-surfaces.mjs' -import { shellCommand } from '../../skill/scripts/parent-platform.mjs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { nativeInvocation, grokControlledTaskEnd, createBackgroundSurfaceAdapters, resolveOfficialCli } from './scenarios-surfaces.mjs' +import * as platform from '../../skill/scripts/parent-platform.mjs' +const { shellCommand } = platform const fixture = () => { const output = '受信processの終了\n', id = 'own-task', session = 'own-session' @@ -65,3 +69,25 @@ test('POSIX起動はargv/env/cwd/stdinを実childへそのまま渡す', async ( const code = await new Promise((resolve, reject) => { child.once('error', reject); child.once('exit', resolve) }) assert.equal(code, 0); assert.deepEqual(JSON.parse(output), { args, cwd, value: "値 $HOME ' \"", input: '日本語 stdin\n' }) }) + + +test('専用hook入口は実ファイルIOで確定行を読み、追記途中と壊れた確定行を区別する', async t => { + const directory = mkdtempSync(join(tmpdir(), 'peertable-surface-hook-')); t.after(() => rmSync(directory, { recursive: true, force: true })) + const adapters = await createBackgroundSurfaceAdapters({ pkg: process.cwd(), tokenFile: join(directory, 'token'), backgroundObserverFactory: () => { throw new Error('この試験では会話observerを起動しません') } }) + for (const harness of ['cursor', 'grok']) { + const adapter = adapters[harness].bind({ directory, project: join(directory, 'project') }) + const value = { event: { conversation_id: 'own', text: '原字面 日本語 &' }, stdout: '{}', code: 0 } + writeFileSync(adapter.hookFile, JSON.stringify(value) + '\n' + '{追記途中') + assert.deepEqual(adapter.hookEvents(), [value]) + writeFileSync(adapter.hookFile, '{壊れた確定行}\n') + assert.throws(() => adapter.hookEvents(), { code: 'ACCEPTANCE_HOOK_EVENT_CORRUPT' }) + } +}) + +test('通常導入の4公式CLIを正規pathと実version応答で解決する', () => { + for (const harness of ['claude', 'codex', 'cursor', 'grok']) { + const actual = resolveOfficialCli(harness, platform) + assert.ok(actual.executable); assert.ok(actual.version) + assert.ok(!/not found|error/iu.test(actual.version)) + } +}) From b734804e250d2b5695c5921a4148fb6aa1bff36d Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:42:02 +0900 Subject: [PATCH 37/72] =?UTF-8?q?=E4=BF=AE=E7=90=86=E5=BE=8C=E5=80=99?= =?UTF-8?q?=E8=A3=9C=E3=81=AE3OS=20CI=E3=81=A8=E9=85=8D=E5=B8=83=E7=89=A9?= =?UTF-8?q?=E3=81=AE=E7=A2=BA=E8=AA=8D=E7=B5=90=E6=9E=9C=E3=82=92=E8=A8=98?= =?UTF-8?q?=E9=8C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plan_parent-native-delivery.md | 4 +- rag/parent-delivery/cli-lifecycle-repairs.md | 4 +- .../release-candidate-checkpoint.json | 43 ++++++++++++------- .../snapshots/a07630d-release-checkpoint.json | 40 +++++++++++++++++ 4 files changed, 73 insertions(+), 18 deletions(-) create mode 100644 rag/parent-delivery/snapshots/a07630d-release-checkpoint.json diff --git a/docs/plan_parent-native-delivery.md b/docs/plan_parent-native-delivery.md index c8f8e6e4..18d4df7e 100644 --- a/docs/plan_parent-native-delivery.md +++ b/docs/plan_parent-native-delivery.md @@ -360,7 +360,7 @@ fixtureは実process境界・競合・障害を速く再現する。実機は親 ## 11. 外部観測待ちからの再開 -配布候補のsource・版・CI・package確認と、GUI観測の停止位置は[チェックポイント](../rag/parent-delivery/release-candidate-checkpoint.json)を参照する。 このチェックポイントの候補`a07630d`は、その後に親終了時の索引撤去を修理したため最終候補ではない。実装文書と配布候補はcommit・push済みだが、実機受入manifestが未達のため、mainへの着地・tag・npm公開・registry導入・本番反映は残る。 +配布候補のsource・版・CI・package確認と、GUI観測の停止位置は[チェックポイント](../rag/parent-delivery/release-candidate-checkpoint.json)を参照する。 旧候補`a07630d`の[過去チェックポイント](../rag/parent-delivery/snapshots/a07630d-release-checkpoint.json)は保存済みで、後続の修理後候補とは分ける。実装文書と配布候補はcommit・push済みだが、実機受入manifestが未達のため、mainへの着地・tag・npm公開・registry導入・本番反映は残る。 GUI試験はJevによる画面取得で停止した。必要なOS権限は確認済みで、専用Cursor projectへの試験メッセージは未送信である。オーナーへGUI試験に限る操作ツール変更を確認中。許可前に代替操作へ進まず、専用の試験ウィンドウとサーバーは閉じている。 @@ -385,3 +385,5 @@ Codexの公式hook本文はXMLのtextとして記録される。[一次仕様の 24 scenarioの手順と実操作moduleをCLI runnerへ接続した。既存contextの11 scenarioに加え、残る13 scenarioには新しい専用project・公式session・spoolを持つnative fixtureを接続する。各操作の実境界が未確認ならtyped errorで止まり、合格を作らない。Cursor/Grokのtask取消・idle・会話切替の観測は仕上げ中である。長時間leaseは通常の製品期限を実際に跨ぎ、期限短縮や時刻書換えで代用しない。修理後の最終sourceを固定してから正式試験を開始する。 試験controllerは製品sourceとは別のcommitと全実行moduleのSHA-256を記録し、実行fileを指定commitのGit blobへ照合する。Windowsの試験起動もPowerShell 7へ統一した。設定解除のJSON比較で空eventを除く補正を廃止し、Codexの信頼entryは専用project配下だけを解除する。自己停止は親終了からの実時間を記録し、30秒を超えた停止やrunnerによる止血を合格にしない。 + +修理後の配布候補は[最新チェックポイント](../rag/parent-delivery/release-candidate-checkpoint.json)に固定した。macOS・Linux・Windows nativeの製品CIは全て合格し、131 fileのpackに新しい起動adapterが含まれ、試験・RAG・ツールの一時状態は含まれないことを確認した。packをmacOSの専用local prefixへnpmで導入し、版・runtime digestと診断readyを確認した。正式受入は修理後の再測定中で、旧候補の6件を新しいmanifestに数えない。registry版のglobal installと本番反映はまだ実施していない。 diff --git a/rag/parent-delivery/cli-lifecycle-repairs.md b/rag/parent-delivery/cli-lifecycle-repairs.md index cad5e3c2..80f397eb 100644 --- a/rag/parent-delivery/cli-lifecycle-repairs.md +++ b/rag/parent-delivery/cli-lifecycle-repairs.md @@ -16,7 +16,7 @@ Codex 0.158.0の通常CLIで、`/quit`時にMCP Jobとその子watcherが一緒 `parent-process.mjs`はWindowsでPowerShell 7のstdinから起動specを渡し、WMI bootstrapが同じwatcherを起動する。env値をargvへ載せない。stdin、cwd、log、watcherの状態遷移は他OSと共通。壊れた外部応答と起動失敗はtyped errorにし、別経路で起動しない。 -Windows実診断ではCodex/MCP終了後もwatcherが存命で、`/quit`から12.6秒で自分で終了し索引を撤去した。診断は`startEndpoint`を呼ぶ専用MCPとCursor種別のspoolを使い、Codex queue配送を避けた。正式`parent_join`試験でも525件の合格でもない。生timeline/eventsは診断scratchpadへ保存し、私物processは終了した。起動契約と失敗経路の3 focused試験はmacOS・Linux・Windowsで合格した。LinuxはNode 24.14.1で実行し、専用の一時fileとprocessを回収した。 +Windows実診断ではCodex/MCP終了後もwatcherが存命で、終了要求の送信開始から12.6秒で自分で終了し索引を撤去した。診断は`startEndpoint`を呼ぶ専用MCPとCursor種別のspoolを使い、Codex queue配送を避けた。正式`parent_join`試験でも525件の合格でもない。生timeline/eventsは診断scratchpadへ保存し、私物processは終了した。起動契約と失敗経路の3 focused試験はmacOS・Linux・Windowsで合格した。LinuxはNode 24.14.1で実行し、専用の一時fileとprocessを回収した。 ## 設定解除 @@ -31,3 +31,5 @@ runnerの空eventを除く意味比較は廃止した。Codexの試験dir信頼 Claude/Cursor/Grokの事前hookはharness・名前・入力digest・PID開始identityを照合する。照合記録の30秒期限切れは`PARENT_BIND_TIMEOUT`として本人不一致と区別し、endpoint作成前に失敗する。Codexは公式thread metadataと実processを直接照合する。 旧候補`43348b8`の6 audienceと18本文の独立監査は、元のsource・本文・判定を保った[snapshot](snapshots/43348b8/rag/parent-delivery/product-acceptance.json)へ保存した。現行manifestに算入しない。修理後の配布sourceと試験controllerをcommitへ固定し、Git blob・digest・版を照合して全必須面の正式試験を行う。main着地、npm公開、registry導入、本番反映はまだ行っていない。 + +Cursorのidle確認には[公式stop仕様](https://cursor.com/docs/hooks#stop)のcompletedと、同じconversation/generationを持つ専用observerを使う。公式資料とCLI実装の確認は、実機発火の合格と分ける。observerの起動時PID・開始identityを保存し、その本人の終了を照合する。親が先に終了した故障試験でも、停止時の所有証拠で私物receiverを再開・回収する。 diff --git a/rag/parent-delivery/release-candidate-checkpoint.json b/rag/parent-delivery/release-candidate-checkpoint.json index 72067105..39707111 100644 --- a/rag/parent-delivery/release-candidate-checkpoint.json +++ b/rag/parent-delivery/release-candidate-checkpoint.json @@ -1,18 +1,29 @@ { "schema": "peertable.parent-release-checkpoint.v1", - "source_commit": "a07630d2feae01945cc1a34dcd2247676c95ecd0", - "runtime_digest": "661241c94f40e184eb0c7d9af295d50cc7ff4e143ecd1d0661703316517e904f", + "source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "runtime_digest": "aab616d50a6a0ec7752f492e1f28cfa73f6afb501b31a3d3eae1041584bfc2f2", "package_version": "0.8.63", "product_ci": { - "url": "https://github.com/kitepon/peertable/actions/runs/36452495551", - "head_commit": "a07630d2feae01945cc1a34dcd2247676c95ecd0", - "native_os": { "darwin": "passed", "linux": "passed", "win32": "passed" } + "url": "https://github.com/kitepon/peertable/actions/runs/36473398201", + "head_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "status": "success", + "native_os": { + "darwin": "passed", + "linux": "passed", + "win32": "passed" + } + }, + "documentation_tests": { + "passed": 6, + "failed": 0, + "skipped": 0 }, - "documentation_tests": { "passed": 6, "failed": 0, "skipped": 0 }, "package": { "filename": "peertable-0.8.63.tgz", - "sha256": "fcf17b36fc9522d5c9b72637f1a20803c1e1ba677e209848a8c5ddf918084300", - "pack_dry_run_files": 128, + "sha256": "9094a3a53b4d66b6322c9ca1e4f23e93810a760d79872b862fe73e26b85dd774", + "pack_dry_run_files": 131, + "pack_files": 131, + "parent_process_included": true, "install_source": "npm_pack_tarball", "install_scope": "fixture_local_prefix", "diagnostics": "ready", @@ -23,18 +34,18 @@ "status": "failed", "expected": 525, "records": 0, - "errors": { "PARENT_ACCEPTANCE_MISSING": 525 }, + "errors": { + "PARENT_ACCEPTANCE_MISSING": 525 + }, "checkpoint_counts_as_live_acceptance": false }, - "cursor_desktop_observation": { + "gui_observation": { "status": "blocked", - "message_sent": false, "tool": "jev-desktop", - "observation_errors": ["ACTION_NOT_SUPPORTED", "AMBIGUOUS_TARGET", "CoreGraphics window inventory timed out"], - "permission_checks": { "accessibility": "granted", "screen_recording": "granted" }, - "test_window_closed": true, - "test_server_alive_after_cleanup": false + "alternative_permission_pending": true, + "test_windows_closed": true }, "published": false, - "production_deployed": false + "production_deployed": false, + "registry_install": false } diff --git a/rag/parent-delivery/snapshots/a07630d-release-checkpoint.json b/rag/parent-delivery/snapshots/a07630d-release-checkpoint.json new file mode 100644 index 00000000..72067105 --- /dev/null +++ b/rag/parent-delivery/snapshots/a07630d-release-checkpoint.json @@ -0,0 +1,40 @@ +{ + "schema": "peertable.parent-release-checkpoint.v1", + "source_commit": "a07630d2feae01945cc1a34dcd2247676c95ecd0", + "runtime_digest": "661241c94f40e184eb0c7d9af295d50cc7ff4e143ecd1d0661703316517e904f", + "package_version": "0.8.63", + "product_ci": { + "url": "https://github.com/kitepon/peertable/actions/runs/36452495551", + "head_commit": "a07630d2feae01945cc1a34dcd2247676c95ecd0", + "native_os": { "darwin": "passed", "linux": "passed", "win32": "passed" } + }, + "documentation_tests": { "passed": 6, "failed": 0, "skipped": 0 }, + "package": { + "filename": "peertable-0.8.63.tgz", + "sha256": "fcf17b36fc9522d5c9b72637f1a20803c1e1ba677e209848a8c5ddf918084300", + "pack_dry_run_files": 128, + "install_source": "npm_pack_tarball", + "install_scope": "fixture_local_prefix", + "diagnostics": "ready", + "registry_install": false + }, + "product_acceptance": { + "manifest": "rag/parent-delivery/product-acceptance.json", + "status": "failed", + "expected": 525, + "records": 0, + "errors": { "PARENT_ACCEPTANCE_MISSING": 525 }, + "checkpoint_counts_as_live_acceptance": false + }, + "cursor_desktop_observation": { + "status": "blocked", + "message_sent": false, + "tool": "jev-desktop", + "observation_errors": ["ACTION_NOT_SUPPORTED", "AMBIGUOUS_TARGET", "CoreGraphics window inventory timed out"], + "permission_checks": { "accessibility": "granted", "screen_recording": "granted" }, + "test_window_closed": true, + "test_server_alive_after_cleanup": false + }, + "published": false, + "production_deployed": false +} From b63922cc4ed87000f2c0a0080384d58eade97596 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:47:02 +0900 Subject: [PATCH 38/72] =?UTF-8?q?=E6=9C=89=E9=99=90lease=E3=82=92=E5=85=AC?= =?UTF-8?q?=E5=BC=8F=E8=83=8C=E6=99=AFtask=E3=81=AE=E5=AE=9F=E7=99=BB?= =?UTF-8?q?=E9=8C=B2=E3=81=A8=E5=85=A8=E9=87=8F=E7=B5=82=E4=BA=86=E5=87=BA?= =?UTF-8?q?=E5=8A=9B=E3=81=B8=E6=9D=9F=E7=B8=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scenarios-native.mjs | 34 +++++++++++++++---- .../scenarios-native.test.mjs | 17 +++++++++- .../scenarios-surfaces.mjs | 18 +++++++++- .../scenarios-surfaces.test.mjs | 27 +++++++++++++-- 4 files changed, 86 insertions(+), 10 deletions(-) diff --git a/experiments/parent-product-acceptance/scenarios-native.mjs b/experiments/parent-product-acceptance/scenarios-native.mjs index d71d3930..f0ecfbca 100644 --- a/experiments/parent-product-acceptance/scenarios-native.mjs +++ b/experiments/parent-product-acceptance/scenarios-native.mjs @@ -119,6 +119,21 @@ export function assertOwnedReceiver(owner, { target, fixture, sameProcess, proce return { endpoint_id: target.spool.id, project: fixture.project, parent_session: target.meta.parent_session, caller_owner: current.caller.owner, receiver_role: role, receiver_owner: owner } } +// lease開始はprobe配送だけで判定せず、公式背景toolの完成済み登録と存命ownerまで待つ。 +export function nativeLeaseRegistration(target, { sameProcess, processDescendsFrom }) { + const current = target.spool.read(), waiter = current.waiter + if (current.state === 'failed' || current.runtime === 'failed') fail(current.error_code ?? 'ACCEPTANCE_LEASE_REGISTER_FAILED', '専用受信登録が失敗しました') + if (current.state !== 'verified' || current.runtime !== 'armed' || !waiter?.owner || !sameProcess(waiter.owner)) return null + if (target.meta.harness === 'claude') return { state: current, waiter, registration: null } + const native = waiter.native_task + if (!native || !sameProcess(native.process_identity) || !processDescendsFrom(waiter.owner, native.process_identity)) return null + const seen = target.observe(), expectedName = target.meta.harness === 'cursor' ? 'Shell' : 'run_terminal_command' + const use = seen.toolUses.find(use => use.name === expectedName && use.session === target.meta.parent_session && isDeepStrictEqual(use.input, native.input?.input)) + const task = use && seen.tasks?.find(task => task.tool_use_id === use.id && String(task.task_id ?? task.id) === native.id && task.pid === native.pid) + if (!use?.turn_id || !task || native.input?.name !== expectedName) return null + return { state: current, waiter, registration: { tool_use_id: use.id, turn_id: use.turn_id, task_id: native.id, input: use.input, owner: native.process_identity, native_task: task } } +} + // 再開は停止時の所有証拠を使う。native親の終了でown receiverの回収を妨げない。 export function assertOwnedResume(owner, { target, fixture, held, sameProcess, endpoints }) { const proof = held?.proof, sameIdentity = (a, b) => a && b && a.pid === b.pid && a.started === b.started @@ -433,17 +448,19 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { } actions.lease_expiry_arm = async (_, s) => { - const target = self(s), fixture = fixtureFor(target), before = target.spool.read() + const target = self(s), fixture = fixtureFor(target) + let before = target.spool.read() if (s.harness === 'codex') { const artifact = s.artifactFor(s, 'queue-connections'), monitor = monitorQueueConnections({ watcher: before.watcher, executable: before.caller.owner.executable, processIdentity: s.processIdentity, sameProcess: s.sameProcess, artifact }) state(s).lease = { before, fixture, monitor, artifact, armed_at: new Date().toISOString() } return s.result(s, 'own_finite_lease', 'os_process', { mechanism: '公式queue接続更新', watcher: before.watcher, process_artifact: artifact, product_connection_source: join(s.pkg, 'skill/scripts/parent-receivers/codex.mjs') }) } - if (!before.waiter?.owner || !s.sameProcess(before.waiter.owner)) fail('ACCEPTANCE_LEASE_OWNER_MISSING', '製品受信processのlease開始を確認できません') + const registered = await s.until('公式受信slotの実登録と存命owner', () => nativeLeaseRegistration(target, s), 120000, 100) + before = registered.state const controller = fixture.adapter.taskController - state(s).lease = { before, fixture, armed_at: new Date().toISOString(), controller } + state(s).lease = { before, fixture, armed_at: new Date().toISOString(), controller, registration: registered.registration } // 製品の有限leaseを短縮・時計改変しない。正式runは実際の期限まで待つ。 - return s.result(s, 'own_finite_lease', s.harness === 'claude' ? 'official_hook' : 'native_task', { endpoint_id: target.spool.id, owner: before.waiter.owner, native_task: before.waiter.native_task ?? null, lease_source: join(s.pkg, `skill/scripts/parent-receivers/${s.harness === 'claude' ? 'claude' : 'background'}.mjs`) }) + return s.result(s, 'own_finite_lease', s.harness === 'claude' ? 'official_hook' : 'native_task', { endpoint_id: target.spool.id, owner: before.waiter.owner, native_task: before.waiter.native_task ?? null, official_registration: registered.registration, lease_source: join(s.pkg, `skill/scripts/parent-receivers/${s.harness === 'claude' ? 'claude' : 'background'}.mjs`) }) } actions.lease_expiry_observe = async (_, s) => { const lease = state(s).lease, target = self(s) @@ -458,7 +475,9 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { return s.result(s, 'finite_lease_control_not_body', 'official_queue', { mechanism: '公式queue接続更新', connection: closed, queued_submission_id: first.receipt.queued_submission_id, accepted_at: first.receipt.accepted_at, queue_snapshot: await lease.fixture.rpc('thread/queue/list', { threadId: target.meta.parent_session, limit: 100 }), process_artifact: lease.artifact, control_notification_required: false }) } // 期限前配送で最初のslotが終了し、親が作る次slotの実expiryを観測する。 - const waiter = await s.until('期限前配送後の次受信slot', () => { const current = target.spool.read().waiter; return current && current.owner.pid !== lease.before.waiter.owner.pid && s.sameProcess(current.owner) ? current : null }, 60000) + const next = await s.until('期限前配送後の次受信slotの実登録', () => { const registered = nativeLeaseRegistration(target, s), current = registered?.waiter; return current && (current.owner.pid !== lease.before.waiter.owner.pid || current.owner.started !== lease.before.waiter.owner.started) ? registered : null }, 120000, 100) + const waiter = next.waiter + lease.next_registration = next.registration const file = s.artifactFor(s, 'lease-control-output'), transcriptBefore = target.observe().rows let output if (s.harness === 'claude') { @@ -470,8 +489,11 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { if (output.outcome !== 'timeout' || !JSON.stringify(output.output).includes('PARENT_RECEIVER_EXPIRED')) fail('ACCEPTANCE_LEASE_CONTROL_MISSING', '公式期限通知を取得できません') writeFileSync(file, JSON.stringify(output)) } + await s.until('有限lease受信processの実終了', () => !s.sameProcess(waiter.owner), 30000, 100) + const expiredObservedAt = new Date().toISOString() + if (!Number.isFinite(Date.parse(waiter.started_at))) fail('ACCEPTANCE_LEASE_START_TIME_MISSING', '実slotの開始時刻がありません') if (target.observe().deliveries.some(delivery => delivery.body?.includes('peertable.parent-control.v1') || delivery.body?.includes('PARENT_RECEIVER_EXPIRED'))) fail('ACCEPTANCE_LEASE_CONTROL_AS_BODY', '制御通知がroom本文へ混入しました') - return s.result(s, 'finite_lease_control_not_body', s.harness === 'claude' ? 'harness_transcript' : 'native_task', { expired_owner: waiter.owner, output_artifact: file, endpoint_id: target.spool.id, cursor: target.spool.read().cursor }) + return s.result(s, 'finite_lease_control_not_body', s.harness === 'claude' ? 'harness_transcript' : 'native_task', { expired_owner: waiter.owner, official_registration: next.registration, expired_native_task: waiter.native_task ?? null, lease_started_at: waiter.started_at, expired_observed_at: expiredObservedAt, observed_elapsed_ms: Date.parse(expiredObservedAt) - Date.parse(waiter.started_at), output_artifact: file, endpoint_id: target.spool.id, cursor: target.spool.read().cursor }) } // この3障害は同じglobal所有hookの実効状態を変える必要がある。共有状態の無断変更は禁止。 diff --git a/experiments/parent-product-acceptance/scenarios-native.test.mjs b/experiments/parent-product-acceptance/scenarios-native.test.mjs index 11b8127c..59d24c12 100644 --- a/experiments/parent-product-acceptance/scenarios-native.test.mjs +++ b/experiments/parent-product-acceptance/scenarios-native.test.mjs @@ -6,7 +6,7 @@ import { createInterface } from 'node:readline' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { competitorProof, queueConnectionProof, monitorQueueConnections, createNativeActions, productToolError, assertOwnedReceiver, assertOwnedResume, pauseOwnedReceiver } from './scenarios-native.mjs' +import { competitorProof, queueConnectionProof, monitorQueueConnections, createNativeActions, productToolError, assertOwnedReceiver, assertOwnedResume, pauseOwnedReceiver, nativeLeaseRegistration } from './scenarios-native.mjs' import { processIdentity, sameProcess, processDescendsFrom } from '../../skill/scripts/parent-platform.mjs' import { scenarioPlan } from './scenarios.mjs' @@ -125,3 +125,18 @@ test('実processで独立receiverを停止し、親が先に終了しても再 const receiverGone = new Promise(resolve => receiver.once('exit', resolve)); receiver.stdin.end(); await receiverGone assert.equal(sameProcess(owner), false) }) + + +test('lease開始はverified後の未登録期間を待ち、公式tool/task登録と本人を照合する', () => { + const owner = { pid: 200, started: 'own開始' }, input = { command: '完成済み', cwd: '/専用' }, native = { id: 'own-task', pid: owner.pid, process_identity: owner, input: { name: 'Shell', input } } + const current = { state: 'verified', runtime: 'rearm_pending', waiter: null }, use = { id: 'use', turn_id: 'turn', name: 'Shell', session: 'own', input }, task = { id: 'own-task', tool_use_id: 'use', pid: owner.pid } + const target = { spool: { read: () => current }, meta: { harness: 'cursor', parent_session: 'own' }, observe: () => ({ toolUses: [use], tasks: [task] }) } + const options = { sameProcess: () => true, processDescendsFrom: () => true } + assert.equal(nativeLeaseRegistration(target, options), null) + current.runtime = 'armed'; current.waiter = { owner, native_task: native } + assert.equal(nativeLeaseRegistration(target, options).registration.task_id, 'own-task') + use.session = '他会話'; assert.equal(nativeLeaseRegistration(target, options), null) + use.session = 'own'; task.pid = 999; assert.equal(nativeLeaseRegistration(target, options), null) + current.state = 'failed'; current.error_code = '実製品error' + assert.throws(() => nativeLeaseRegistration(target, options), { code: current.error_code }) +}) diff --git a/experiments/parent-product-acceptance/scenarios-surfaces.mjs b/experiments/parent-product-acceptance/scenarios-surfaces.mjs index e1ac648c..04ad14cf 100644 --- a/experiments/parent-product-acceptance/scenarios-surfaces.mjs +++ b/experiments/parent-product-acceptance/scenarios-surfaces.mjs @@ -5,6 +5,7 @@ import { homedir } from 'node:os' import { execFileSync } from 'node:child_process' import { pathToFileURL } from 'node:url' import { isDeepStrictEqual } from 'node:util' +import { createHash } from 'node:crypto' const fail = (code, message) => { throw Object.assign(new Error(message), { code }) } // hook専用の確定行だけを読む。harnessの起動adapterへ逆依存せず、未確定末尾を保留する。 @@ -54,6 +55,20 @@ export function grokControlledTaskEnd(seen, request, { sameProcess }) { return { id, session, finished: true, outcome: operation, delivered: products.some(value => value.outcome === 'ready'), output: result.output, artifact: result.output_file, exit_code: snapshot.exit_code, signal: snapshot.signal, task_snapshot: snapshot, control, reader_tool_use_id: reader.id, official_reader: reader } } +// 背景制御通知も同taskの公式全量readerから取得する。任意Readや別会話の出力は採用しない。 +export function assertNativeTaskRead({ harness, id, session, endpointId, project, read, completed, toolUses }) { + const result = read.result, reader = toolUses.find(use => use.id === read.reader_tool_use_id && use.session === session) + if (read.session !== session || result.endpoint_id !== endpointId || !reader) fail('ACCEPTANCE_NATIVE_TASK_RESULT_OWNER', '同task/CID/endpointの公式readerではありません') + if (harness === 'cursor') { + const path = join(homedir(), '.cursor/projects', project.replace(/[^a-zA-Z0-9]+/gu, '-').replace(/^-+/u, ''), 'terminals', `${id}.txt`) + if (reader.name !== 'Read' || !isDeepStrictEqual(reader.input, { path }) || read.output_file !== path || !Buffer.isBuffer(read.raw_bytes)) fail('ACCEPTANCE_CURSOR_NATIVE_FULL_READ', 'Cursor公式taskファイルの全量Readではありません') + } else { + const snapshot = completed.raw_output, output = read.raw_output + if (snapshot.owner_session_id !== session || reader.name !== 'get_command_or_subagent_output' || !isDeepStrictEqual(reader.input, { task_ids: [String(id)] }) || reader.order <= completed.order || output.status !== 'completed' || output.truncated !== false || output.output !== snapshot.output || output.output_file !== snapshot.output_file || output.raw_output_bytes !== snapshot.output_total_bytes || Buffer.byteLength(output.output, 'utf8') !== snapshot.output_total_bytes) fail('ACCEPTANCE_GROK_NATIVE_FULL_READ', 'Grok同会話/taskの完了snapshotと全量readerが一致しません') + } + return reader +} + export async function createBackgroundSurfaceAdapters({ pkg, tokenFile, backgroundObserverFactory }) { if (!backgroundObserverFactory) { try { backgroundObserverFactory = (await import('./background-harness.mjs')).createBackgroundHarnessObserver } @@ -100,7 +115,8 @@ export async function createBackgroundSurfaceAdapters({ pkg, tokenFile, backgrou const completed = records.find(task => task.raw_output?.completed === true) if (harness === 'grok' && !completed) fail('ACCEPTANCE_GROK_NATIVE_TASK_END_MISSING', '実taskの完了snapshotがありません') const result = read.result - return { id: String(id), session, finished: true, outcome: result.outcome === 'timeout' && result.error_code === 'PARENT_RECEIVER_EXPIRED' ? 'timeout' : result.outcome, output: result, artifact: read.output_file, exit_code: completed?.raw_output?.exit_code ?? null, delivered: result.outcome === 'ready', reader_tool_use_id: read.reader_tool_use_id } + const reader = assertNativeTaskRead({ harness, id, session, endpointId: fixture.target.spool.id, project: fixture.project, read, completed, toolUses: seen.toolUses }) + return { id: String(id), session, finished: true, outcome: result.outcome === 'timeout' && result.error_code === 'PARENT_RECEIVER_EXPIRED' ? 'timeout' : result.outcome, output: result, artifact: read.output_file, exit_code: completed?.raw_output?.exit_code ?? null, delivered: result.outcome === 'ready', reader_tool_use_id: read.reader_tool_use_id, official_reader: reader, native_task_completion: completed?.raw_output ?? null, native_read_bytes_sha256: harness === 'cursor' ? createHash('sha256').update(read.raw_bytes).digest('hex') : createHash('sha256').update(read.raw_output.output, 'utf8').digest('hex') } }, async request(operation, { id, session }) { if (operation === 'timeout') return { operation: '通常製品leaseの実期限まで待機', id: String(id), session, timeout_shortened: false } diff --git a/experiments/parent-product-acceptance/scenarios-surfaces.test.mjs b/experiments/parent-product-acceptance/scenarios-surfaces.test.mjs index ee56dcfb..8105cef2 100644 --- a/experiments/parent-product-acceptance/scenarios-surfaces.test.mjs +++ b/experiments/parent-product-acceptance/scenarios-surfaces.test.mjs @@ -3,9 +3,9 @@ import test from 'node:test' import assert from 'node:assert/strict' import { spawn } from 'node:child_process' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { tmpdir, homedir } from 'node:os' import { join } from 'node:path' -import { nativeInvocation, grokControlledTaskEnd, createBackgroundSurfaceAdapters, resolveOfficialCli } from './scenarios-surfaces.mjs' +import { nativeInvocation, grokControlledTaskEnd, createBackgroundSurfaceAdapters, resolveOfficialCli, assertNativeTaskRead } from './scenarios-surfaces.mjs' import * as platform from '../../skill/scripts/parent-platform.mjs' const { shellCommand } = platform @@ -91,3 +91,26 @@ test('通常導入の4公式CLIを正規pathと実version応答で解決する', assert.ok(!/not found|error/iu.test(actual.version)) } }) + + +test('期限通知のCursor Readはown native taskファイルの全量に限定する', () => { + const project = '/専用 lease project', id = 'task', session = 'own', endpointId = 'endpoint', path = join(homedir(), '.cursor/projects', project.replace(/[^a-zA-Z0-9]+/gu, '-').replace(/^-+/u, ''), 'terminals', `${id}.txt`) + const reader = { id: 'read-use', session, name: 'Read', input: { path } }, read = { reader_tool_use_id: reader.id, session, result: { endpoint_id: endpointId }, output_file: path, raw_bytes: Buffer.from('元bytes') } + const options = { harness: 'cursor', id, session, endpointId, project, read, toolUses: [reader] } + assert.equal(assertNativeTaskRead(options), reader) + reader.input.limit = 1 + assert.throws(() => assertNativeTaskRead(options), { code: 'ACCEPTANCE_CURSOR_NATIVE_FULL_READ' }) + delete reader.input.limit; read.session = '他会話' + assert.throws(() => assertNativeTaskRead(options), { code: 'ACCEPTANCE_NATIVE_TASK_RESULT_OWNER' }) +}) + +test('期限通知のGrok readerは同会話taskの完了snapshotと全文byte数を照合する', () => { + const { seen, request } = fixture(), reader = seen.toolUses[1], completed = seen.tasks[0] + const read = { session: request.session, reader_tool_use_id: reader.id, result: { endpoint_id: 'own-endpoint' }, raw_output: reader.output.Result } + const options = { harness: 'grok', ...request, endpointId: 'own-endpoint', project: '/専用', read, completed, toolUses: seen.toolUses } + assert.equal(assertNativeTaskRead(options), reader) + reader.input.timeout_ms = 1 + assert.throws(() => assertNativeTaskRead(options), { code: 'ACCEPTANCE_GROK_NATIVE_FULL_READ' }) + delete reader.input.timeout_ms; read.raw_output.raw_output_bytes += 1 + assert.throws(() => assertNativeTaskRead(options), { code: 'ACCEPTANCE_GROK_NATIVE_FULL_READ' }) +}) From ba564186e4cd94b0ddf45a2ca244077ea682f844 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:01:29 +0900 Subject: [PATCH 39/72] =?UTF-8?q?=E4=BF=AE=E7=90=86=E5=BE=8C=E3=81=AEMac?= =?UTF-8?q?=E8=A6=AA=E9=85=8D=E9=80=81=E3=82=92=E5=8E=9F=E8=A8=BC=E6=8B=A0?= =?UTF-8?q?=E3=81=8B=E3=82=89=E7=9B=A3=E6=9F=BB=E3=81=97=E5=8F=97=E5=85=A5?= =?UTF-8?q?=E7=9B=AE=E9=8C=B2=E3=81=B8=E7=99=BB=E9=8C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plan_parent-native-delivery.md | 4 +- rag/INDEX.md | 1 + .../cli-formal-review-017bf2c.json | 191 +++++++ .../live/darwin/claude/cli/audience.json | 497 ++++++++++++++++++ .../live/darwin/codex/cli/audience.json | 487 +++++++++++++++++ rag/parent-delivery/product-acceptance.json | 85 ++- .../release-candidate-checkpoint.json | 4 +- 7 files changed, 1265 insertions(+), 4 deletions(-) create mode 100644 rag/parent-delivery/cli-formal-review-017bf2c.json create mode 100644 rag/parent-delivery/live/darwin/claude/cli/audience.json create mode 100644 rag/parent-delivery/live/darwin/codex/cli/audience.json diff --git a/docs/plan_parent-native-delivery.md b/docs/plan_parent-native-delivery.md index 18d4df7e..18741866 100644 --- a/docs/plan_parent-native-delivery.md +++ b/docs/plan_parent-native-delivery.md @@ -380,10 +380,12 @@ Codexの公式hook本文はXMLのtextとして記録される。[一次仕様の 旧候補`43348b8`の実機試験で、Linuxの実行中binary更新と、WindowsのCodex終了時に配送watcherが消される問題を再現した。設定解除ではCodexのTOMLに空行が残り、ClaudeのJSONに導入時の空eventが残った。これらの修理により旧候補を最終sourceとして扱えなくなった。[修理の原因と検証](../rag/parent-delivery/cli-lifecycle-repairs.md)へ実測と未確認の境界を保存する。 -旧候補で独立監査したCursor/Grokの6 CLI audience・18本文は、[試験当時のmanifest](../rag/parent-delivery/snapshots/43348b8/rag/parent-delivery/product-acceptance.json)と同じsnapshot内の証拠へ移して保存した。本文・source・判定は書き換えない。Linux Grokの複数宛とWindows Grokの画面取得不足も保持する。[現在の受入manifest](../rag/parent-delivery/product-acceptance.json)は修理後の再測定まで空とし、旧候補の成功で公開gateを通さない。 +旧候補で独立監査したCursor/Grokの6 CLI audience・18本文は、[試験当時のmanifest](../rag/parent-delivery/snapshots/43348b8/rag/parent-delivery/product-acceptance.json)と同じsnapshot内の証拠へ移して保存した。本文・source・判定は書き換えない。Linux Grokの複数宛とWindows Grokの画面取得不足も保持する。[現在の受入manifest](../rag/parent-delivery/product-acceptance.json)には修理後に再測定し、原証拠を独立照合した項目だけを置く。旧候補の成功で公開gateを通さない。 24 scenarioの手順と実操作moduleをCLI runnerへ接続した。既存contextの11 scenarioに加え、残る13 scenarioには新しい専用project・公式session・spoolを持つnative fixtureを接続する。各操作の実境界が未確認ならtyped errorで止まり、合格を作らない。Cursor/Grokのtask取消・idle・会話切替の観測は仕上げ中である。長時間leaseは通常の製品期限を実際に跨ぎ、期限短縮や時刻書換えで代用しない。修理後の最終sourceを固定してから正式試験を開始する。 試験controllerは製品sourceとは別のcommitと全実行moduleのSHA-256を記録し、実行fileを指定commitのGit blobへ照合する。Windowsの試験起動もPowerShell 7へ統一した。設定解除のJSON比較で空eventを除く補正を廃止し、Codexの信頼entryは専用project配下だけを解除する。自己停止は親終了からの実時間を記録し、30秒を超えた停止やrunnerによる止血を合格にしない。 修理後の配布候補は[最新チェックポイント](../rag/parent-delivery/release-candidate-checkpoint.json)に固定した。macOS・Linux・Windows nativeの製品CIは全て合格し、131 fileのpackに新しい起動adapterが含まれ、試験・RAG・ツールの一時状態は含まれないことを確認した。packをmacOSの専用local prefixへnpmで導入し、版・runtime digestと診断readyを確認した。正式受入は修理後の再測定中で、旧候補の6件を新しいmanifestに数えない。registry版のglobal installと本番反映はまだ実施していない。 + +修理後のmacOS Claude/Codex通常CLIのaudienceは、[独立監査](../rag/parent-delivery/cli-formal-review-017bf2c.json)で共通pack、controllerのGit blob、room原文、実会話の本文と後続返答、spool、SQLite receiptを照合した。各3通が一致し、終了後の自動停止と設定解除も確認できたため、現在の受入manifestへ実測sourceを維持して追加した。短期scenarioの実作業file拡張子と記録反映の競合はcontroller側の修理中であり、失敗した項目を合格に数えない。長時間leaseと他OS・Desktop・IDEの受入は残る。 diff --git a/rag/INDEX.md b/rag/INDEX.md index ccc406d7..0edc94b4 100644 --- a/rag/INDEX.md +++ b/rag/INDEX.md @@ -6,3 +6,4 @@ - [修理後の通常CLI候補の観測](parent-delivery/repaired-cli-candidate-observation.json) — macOS Claude/Codexの原文・返答・receiptと、親終了後の自動停止・索引撤去・設定復元。 - [Cursor/Grokの正式CLI証拠の親監査](parent-delivery/cursor-grok-formal-review.json) — 旧候補の6 audience、18本文と公式turn・後続返答の独立照合、画面取得の不足。 - [CLIの終了・設定解除・本人相関の修理](parent-delivery/cli-lifecycle-repairs.md) — Linuxの削除済みbinary、Windows Codex Job、公式設定解除とJSON構造、旧証跡の保存と再測定条件。 +- [修理後候補のCLI正式証拠の独立監査](parent-delivery/cli-formal-review-017bf2c.json) — macOS Claude/Codexのaudience原文・実会話・receipt・controller・pack・終了回収の照合。 diff --git a/rag/parent-delivery/cli-formal-review-017bf2c.json b/rag/parent-delivery/cli-formal-review-017bf2c.json new file mode 100644 index 00000000..734960a1 --- /dev/null +++ b/rag/parent-delivery/cli-formal-review-017bf2c.json @@ -0,0 +1,191 @@ +{ + "schema": "peertable.parent-independent-review.v1", + "reports": [ + { + "case_id": "darwin/claude/cli/audience", + "status": "passed", + "source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "evidence_path": "/private/tmp/claude-501/-Users-kite--codex-worktrees-parent-acceptance-runner-peertable/6578ec5b-7de6-4c99-87a4-7a9b9114360b/scratchpad/acc/mac/audience-claude/public/rag/parent-delivery/live/darwin/claude/cli/audience.json", + "runner_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "package_tarball_sha256": "9094a3a53b4d66b6322c9ca1e4f23e93810a760d79872b862fe73e26b85dd774", + "raw_transcript_sha256": "1d97efe793aeacf628a7ef4c660b909d8039134d3ed584e63c6bbd7b4ed8d372", + "raw_spool_sha256": "481b6710846140ae30f59e6c12b58dcc409813b32f4caa4e2727d35a3330e2e5", + "checks": [ + { + "label": "dm", + "seq": 3, + "delivery_id": "2f854a38-f45a-4a0f-b488-90ff6abc6665", + "turn_id": "87119b43-826d-4e3d-8307-89be6efc2216", + "reply_turn_id": "87119b43-826d-4e3d-8307-89be6efc2216", + "body_sha256": "3c54e47647eea0d5ac32c421f1b1921326ddf8fe3cc12f0b204169aa4888e663", + "body_equal": true, + "receipt_revision": 1 + }, + { + "label": "multiple", + "seq": 4, + "delivery_id": "decc001f-b62f-4b09-bc46-2469d27336b6", + "turn_id": "a69598ae-770d-45be-905a-fa09abca2b05", + "reply_turn_id": "a69598ae-770d-45be-905a-fa09abca2b05", + "body_sha256": "a89ddf33a4727b0f7a2c74887a36a2d3faf3ad11b9ee4b39c71f05186cdb3642", + "body_equal": true, + "receipt_revision": 1 + }, + { + "label": "all", + "seq": 5, + "delivery_id": "2e82da28-d24a-4ea3-a631-cf806e5f75fc", + "turn_id": "53613061-cb2f-4019-b2fe-075dce99b432", + "reply_turn_id": "53613061-cb2f-4019-b2fe-075dce99b432", + "body_sha256": "2eab1802ca8c7eed65473682210b499cda97e2c604627b9d69c984ec7fe2785c", + "body_equal": true, + "receipt_revision": 1 + } + ], + "cleanup": [ + { + "label": "harness_exit", + "status": "done", + "pid": 62478, + "exited": true + }, + { + "label": "endpoint_stop", + "status": "done", + "product_index_left_after_stop": false, + "runtime_before": "stopped", + "runtime_after": "stopped", + "product_stopped_within_30s": true, + "product_stop_latency_ms": 2, + "stopped_by_runner": false, + "owned_alive_after": [] + }, + { + "label": "pty_close", + "status": "done", + "session": "ptacc-claude-43776a08", + "outcome": "already_closed", + "pane_pid": 62478, + "pane_alive_after": false + }, + { + "label": "room_server", + "status": "done", + "pid": 62426, + "exit": "SIGTERM", + "alive_after": false + }, + { + "label": "connect_remove", + "status": "done", + "removed": [ + "claude:removed" + ], + "semantic_equal": true, + "settings_diff_keys": [], + "mcp_diff_keys": [], + "user_keys_added": [], + "backup": null + } + ], + "negative_delivery_scope": "当該audience原証拠には他人宛DM/self投稿は含まれていない" + }, + { + "case_id": "darwin/codex/cli/audience", + "status": "passed", + "source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "evidence_path": "/private/tmp/claude-501/-Users-kite--codex-worktrees-parent-acceptance-runner-peertable/6578ec5b-7de6-4c99-87a4-7a9b9114360b/scratchpad/acc/mac/audience-codex/public/rag/parent-delivery/live/darwin/codex/cli/audience.json", + "runner_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "package_tarball_sha256": "9094a3a53b4d66b6322c9ca1e4f23e93810a760d79872b862fe73e26b85dd774", + "raw_transcript_sha256": "180e13666945cffdba16725c23a74720dd61e6a9fcc28815fe3c1f25e53d8856", + "raw_spool_sha256": "d298a3deca56740a13707ae7bcc758924f7a4fddaee857f7f5e81831c967e502", + "checks": [ + { + "label": "dm", + "seq": 3, + "delivery_id": "e3df1dd7-c145-442e-83a4-440fbef4aaaf", + "turn_id": "01a0e989-44fe-7131-80ca-8607061db73f", + "reply_turn_id": "01a0e989-44fe-7131-80ca-8607061db73f", + "body_sha256": "49c46447eac89564a78b66b3a657f78918930f5f39ba5747f6542b2f0c231c42", + "body_equal": true, + "receipt_revision": 2 + }, + { + "label": "multiple", + "seq": 4, + "delivery_id": "5acdfa56-7a2a-4d52-84c2-7ee5966f1fa4", + "turn_id": "01a0e989-bb2a-7580-bb6e-0c985c492b3f", + "reply_turn_id": "01a0e989-bb2a-7580-bb6e-0c985c492b3f", + "body_sha256": "0543f36241800ba6ceba06125fb2f95b4f874c309c69d58bcf0bd56a1a9bae64", + "body_equal": true, + "receipt_revision": 1 + }, + { + "label": "all", + "seq": 5, + "delivery_id": "0be9d2d6-be47-4946-a59e-551bd54c0ccf", + "turn_id": "01a0e989-e247-7713-b3a7-097cca48d1cf", + "reply_turn_id": "01a0e989-e247-7713-b3a7-097cca48d1cf", + "body_sha256": "13e2ff50357eb842540525a44f86031b7ef72fe90da563deffc142fd0a801718", + "body_equal": true, + "receipt_revision": 1 + } + ], + "cleanup": [ + { + "label": "harness_exit", + "status": "done", + "pid": 66124, + "exited": true + }, + { + "label": "endpoint_stop", + "status": "done", + "product_index_left_after_stop": false, + "runtime_before": "stopped", + "runtime_after": "stopped", + "product_stopped_within_30s": true, + "product_stop_latency_ms": 7314, + "stopped_by_runner": false, + "owned_alive_after": [] + }, + { + "label": "codex_folder_trust_remove", + "status": "done", + "removed_trust_entries": 1, + "remaining_trust_entries": 0 + }, + { + "label": "pty_close", + "status": "done", + "session": "ptacc-codex-45a0c8fc", + "outcome": "already_closed", + "pane_pid": 66124, + "pane_alive_after": false + }, + { + "label": "room_server", + "status": "done", + "pid": 66072, + "exit": "SIGTERM", + "alive_after": false + }, + { + "label": "connect_remove", + "status": "done", + "removed": [ + "codex:removed" + ], + "semantic_equal": true, + "effective_diff_keys": [], + "hooks_diff_keys": [], + "config_text_equal": true, + "backup": null + } + ], + "negative_delivery_scope": "当該audience原証拠には他人宛DM/self投稿は含まれていない" + } + ], + "source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "scope": "macOS Claude/Codex通常CLIのaudience。原証拠から本文・会話・receipt・controller・pack・自己停止を独立照合した。全525項目の合格ではない。" +} diff --git a/rag/parent-delivery/live/darwin/claude/cli/audience.json b/rag/parent-delivery/live/darwin/claude/cli/audience.json new file mode 100644 index 00000000..19870692 --- /dev/null +++ b/rag/parent-delivery/live/darwin/claude/cli/audience.json @@ -0,0 +1,497 @@ +{ + "schema": "peertable.parent-live-case.v1", + "case_id": "darwin/claude/cli/audience", + "status": "passed", + "reasons": [], + "native": true, + "os": "darwin", + "harness": "claude", + "surface": "cli", + "source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "runtime_digest": "aab616d50a6a0ec7752f492e1f28cfa73f6afb501b31a3d3eae1041584bfc2f2", + "package_version": "0.8.63", + "client_version": "0.8.63", + "package_tarball_sha256": "9094a3a53b4d66b6322c9ca1e4f23e93810a760d79872b862fe73e26b85dd774", + "harness_version": "2.1.284 (Claude Code)", + "node_version": "v26.10.0", + "parent_session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "parent_process": { + "pid": 62478, + "parent": 1199, + "started": "Tue Sep 29 04:38:59 2026", + "executable": "/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe", + "command": "claude --model claude-sonnet-5-5" + }, + "endpoint_id": "6fdf6a52-3a3b-48fa-8c76-d90ef8d18bd3", + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "owner_input_required": false, + "isolation": { + "home": "normal", + "room": "local_private_room_server", + "session_args": [], + "folder_trust": "起動dialogで試験dirを信頼(Claudeのproject履歴に残る)" + }, + "controller": { + "files_sha256": { + "README.md": "b388692fe0b584dd88ef73180bb9cebd5bf8e20b22c38a930251c1873c2a4a10", + "aiterm.mjs": "c3fa88a1032455dd70698e567d0e364d4714a6c3afd96cff46d5aad6ab953b16", + "background-harness.mjs": "40614a98000ec761dc38c6ba54a114c744db450d7efa7bc67a1276ab9a5ddca0", + "evidence.mjs": "216a4667764bcd7b7c402e2d81353f5c3cba200f20a303b1b1abb80735280490", + "harness.mjs": "282d2bf7d8aff8493a05eaef9903e2eadda85e3d4ef45a7762242d0f209f3712", + "run.mjs": "1b0aad3d3be8bf2a430c6469559b3d00bd579ed6d8e0bb4eb3582a5e4b4fc1ac", + "runner-provenance.mjs": "ec44651161c6f27bad824c75102a299c0ce6502b8ab6b7f640114f6488e4ad74", + "scenarios-context.mjs": "aa569402743406df39d9ff4d0bdf50fe7dcbd1c4ff3f2dd1858be5c44aa403e5", + "scenarios-fixtures.mjs": "f4ad05cafcede3132c322fee4f768626b97275e051219cc70241a0286a1e0bf8", + "scenarios-lease-run.mjs": "6348a310b1f56a350eb6c38c3bcd6b729bef369da408229a1a837a55f024becc", + "scenarios-native.mjs": "cce9267bc404b41c89e56608888086b3efb4aa1ff89af934f510358dd41b42fe", + "scenarios-surfaces.mjs": "bf5fab85bbdb8d50f4182d0db635779c0ced280eb0fda7ddcb28423da5906ea8", + "scenarios.mjs": "08db776c879b02b8da7b6263511256b192d35a6d78fdaf1164e05328184fca98" + } + }, + "runner": { + "commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "modules_sha256": { + "README.md": "b388692fe0b584dd88ef73180bb9cebd5bf8e20b22c38a930251c1873c2a4a10", + "aiterm.mjs": "c3fa88a1032455dd70698e567d0e364d4714a6c3afd96cff46d5aad6ab953b16", + "background-harness.mjs": "40614a98000ec761dc38c6ba54a114c744db450d7efa7bc67a1276ab9a5ddca0", + "evidence.mjs": "216a4667764bcd7b7c402e2d81353f5c3cba200f20a303b1b1abb80735280490", + "harness.mjs": "282d2bf7d8aff8493a05eaef9903e2eadda85e3d4ef45a7762242d0f209f3712", + "run.mjs": "1b0aad3d3be8bf2a430c6469559b3d00bd579ed6d8e0bb4eb3582a5e4b4fc1ac", + "runner-provenance.mjs": "ec44651161c6f27bad824c75102a299c0ce6502b8ab6b7f640114f6488e4ad74", + "scenarios-context.mjs": "aa569402743406df39d9ff4d0bdf50fe7dcbd1c4ff3f2dd1858be5c44aa403e5", + "scenarios-fixtures.mjs": "f4ad05cafcede3132c322fee4f768626b97275e051219cc70241a0286a1e0bf8", + "scenarios-lease-run.mjs": "6348a310b1f56a350eb6c38c3bcd6b729bef369da408229a1a837a55f024becc", + "scenarios-native.mjs": "cce9267bc404b41c89e56608888086b3efb4aa1ff89af934f510358dd41b42fe", + "scenarios-surfaces.mjs": "bf5fab85bbdb8d50f4182d0db635779c0ced280eb0fda7ddcb28423da5906ea8", + "scenarios.mjs": "08db776c879b02b8da7b6263511256b192d35a6d78fdaf1164e05328184fca98" + }, + "product_source_is_separate": true + }, + "observations": [ + { + "kind": "parent_join", + "parent_session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "turn_id": "053a2451-649b-41e8-b6bf-d4511b33f96f", + "tool_use_id": "toolu_01WbrdxbF8UUaWaJxqdHZcik", + "tool_name": "mcp__peertable_parent__parent_join", + "endpoint_id": "6fdf6a52-3a3b-48fa-8c76-d90ef8d18bd3", + "caller_owner": { + "pid": 62478, + "parent": 1199, + "started": "Tue Sep 29 04:38:59 2026", + "executable": "/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe", + "command": "claude --model claude-sonnet-5-5" + }, + "call_generation": "febdffe9-4819-4504-a3b1-b96ca526497b" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "dm", + "parent_session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "turn_id": "87119b43-826d-4e3d-8307-89be6efc2216", + "seq": 3, + "delivery_id": "2f854a38-f45a-4a0f-b488-90ff6abc6665", + "entry": { + "uuid": "fdd08004-cb36-4e29-a3ef-7cfb344e5dac", + "type": "user", + "role": null + }, + "native_hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "native_queue": [ + "enqueue" + ], + "native_ack_id": "fdd08004-cb36-4e29-a3ef-7cfb344e5dac" + }, + { + "kind": "parent_reply_after_delivery", + "label": "dm", + "parent_session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "turn_id": "87119b43-826d-4e3d-8307-89be6efc2216", + "seq": 3, + "entry_uuid": "41bd9b94-1398-49e8-80ec-84ca534845fb" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "multiple", + "parent_session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "turn_id": "a69598ae-770d-45be-905a-fa09abca2b05", + "seq": 4, + "delivery_id": "decc001f-b62f-4b09-bc46-2469d27336b6", + "entry": { + "uuid": "28996c02-640f-44e4-bce6-ab939816a854", + "type": "user", + "role": null + }, + "native_hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "native_queue": [ + "enqueue" + ], + "native_ack_id": "28996c02-640f-44e4-bce6-ab939816a854" + }, + { + "kind": "parent_reply_after_delivery", + "label": "multiple", + "parent_session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "turn_id": "a69598ae-770d-45be-905a-fa09abca2b05", + "seq": 4, + "entry_uuid": "3989f69a-8589-4d86-943d-ae4a9ec50596" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "all", + "parent_session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "turn_id": "53613061-cb2f-4019-b2fe-075dce99b432", + "seq": 5, + "delivery_id": "2e82da28-d24a-4ea3-a631-cf806e5f75fc", + "entry": { + "uuid": "773c9a39-9edc-4db9-b1ee-8c54d5a099f9", + "type": "user", + "role": null + }, + "native_hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "native_queue": [ + "enqueue" + ], + "native_ack_id": "773c9a39-9edc-4db9-b1ee-8c54d5a099f9" + }, + { + "kind": "parent_reply_after_delivery", + "label": "all", + "parent_session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "turn_id": "53613061-cb2f-4019-b2fe-075dce99b432", + "seq": 5, + "entry_uuid": "0e63ce98-16eb-4221-9308-c5c1ff669a60" + } + ], + "receipts": [ + { + "seq": 3, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + } + ], + "body_checks": [ + { + "label": "dm", + "original": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_DM_d4de6a5e-0f50-4d8b-9450-d8fcdc47f1f4 です。" + }, + "received": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_DM_d4de6a5e-0f50-4d8b-9450-d8fcdc47f1f4 です。" + } + }, + { + "label": "multiple", + "original": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_d57504e7-1bfb-4f23-992a-a8d87f5ef5c4 です。" + }, + "received": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_d57504e7-1bfb-4f23-992a-a8d87f5ef5c4 です。" + } + }, + { + "label": "all", + "original": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_ALL_c9c170cc-9a1f-4b34-bd99-c334a97f5457 です。" + }, + "received": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_ALL_c9c170cc-9a1f-4b34-bd99-c334a97f5457 です。" + } + } + ], + "checks": [ + { + "status": "passed", + "reasons": [], + "label": "dm", + "recipient": "bell", + "original": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_DM_d4de6a5e-0f50-4d8b-9450-d8fcdc47f1f4 です。" + }, + "received": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_DM_d4de6a5e-0f50-4d8b-9450-d8fcdc47f1f4 です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_DM_d4de6a5e-0f50-4d8b-9450-d8fcdc47f1f4 です。", + "delivery_id": "2f854a38-f45a-4a0f-b488-90ff6abc6665", + "digest": "793e21ef9134ee83a59b92b61cb60f5572eb9ebb274aab621ae973caf6e63164", + "preview": false, + "order": 71, + "session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "turn_id": "87119b43-826d-4e3d-8307-89be6efc2216", + "hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "entry": { + "uuid": "fdd08004-cb36-4e29-a3ef-7cfb344e5dac", + "type": "user", + "role": null + } + }, + "reply": { + "order": 72, + "session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "turn_id": "87119b43-826d-4e3d-8307-89be6efc2216", + "entry_uuid": "41bd9b94-1398-49e8-80ec-84ca534845fb", + "text": "四件目の観測データを受信しました。配送確認の符号を含んでいます。\n\n- **符号**: `PEERTABLE_ACCEPT_CLAUDE_DM_d4de6a5e-0f50-4d8b-9450-d8fcdc47f1f4`\n- **送信者**: probe\n- **宛先**: bell\n- **seq**: 3\n- **本文**: 「日本語の観測」に続いて、引用符・絵文字・`>`・``・`&`・`'q'` を含む文字列\n- **room**: `parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34`\n- **配送ID**: `2f854a38-f45a-4a0f-b488-90ff6abc6665`\n\n本文の特殊文字は、そのまま表示された状態で届いています。行動命令はありません。" + }, + "spool": { + "delivery_id": "2f854a38-f45a-4a0f-b488-90ff6abc6665", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "claude_asyncRewake_output" + }, + "receipt": { + "seq": 3, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + } + }, + { + "status": "passed", + "reasons": [], + "label": "multiple", + "recipient": "bell", + "original": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_d57504e7-1bfb-4f23-992a-a8d87f5ef5c4 です。" + }, + "received": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_d57504e7-1bfb-4f23-992a-a8d87f5ef5c4 です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_d57504e7-1bfb-4f23-992a-a8d87f5ef5c4 です。", + "delivery_id": "decc001f-b62f-4b09-bc46-2469d27336b6", + "digest": "5d58304a692e68be2536e20ca6ba43dd23302a7de91e06772d9ba2d1505f6ce6", + "preview": false, + "order": 77, + "session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "turn_id": "a69598ae-770d-45be-905a-fa09abca2b05", + "hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "entry": { + "uuid": "28996c02-640f-44e4-bce6-ab939816a854", + "type": "user", + "role": null + } + }, + "reply": { + "order": 78, + "session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "turn_id": "a69598ae-770d-45be-905a-fa09abca2b05", + "entry_uuid": "3989f69a-8589-4d86-943d-ae4a9ec50596", + "text": "五件目の観測データを受信しました。配送確認の符号を含んでいます。\n\n- **符号**: `PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_d57504e7-1bfb-4f23-992a-a8d87f5ef5c4`\n- **送信者**: probe\n- **宛先**: bell と rui(複数宛)\n- **seq**: 4\n- **本文**: 前回と同じ特殊文字入りの「日本語の観測」\n- **room**: `parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34`\n- **配送ID**: `decc001f-b62f-4b09-bc46-2469d27336b6`\n\n行動命令はありません。" + }, + "spool": { + "delivery_id": "decc001f-b62f-4b09-bc46-2469d27336b6", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "claude_asyncRewake_output" + }, + "receipt": { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + } + }, + { + "status": "passed", + "reasons": [], + "label": "all", + "recipient": "bell", + "original": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_ALL_c9c170cc-9a1f-4b34-bd99-c334a97f5457 です。" + }, + "received": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_ALL_c9c170cc-9a1f-4b34-bd99-c334a97f5457 です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_ALL_c9c170cc-9a1f-4b34-bd99-c334a97f5457 です。", + "delivery_id": "2e82da28-d24a-4ea3-a631-cf806e5f75fc", + "digest": "1c8bfec362e650f6c06042980738222548fa5f8efbb753e58653f540bc2620af", + "preview": false, + "order": 83, + "session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "turn_id": "53613061-cb2f-4019-b2fe-075dce99b432", + "hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "entry": { + "uuid": "773c9a39-9edc-4db9-b1ee-8c54d5a099f9", + "type": "user", + "role": null + } + }, + "reply": { + "order": 84, + "session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "turn_id": "53613061-cb2f-4019-b2fe-075dce99b432", + "entry_uuid": "0e63ce98-16eb-4221-9308-c5c1ff669a60", + "text": "六件目の観測データを受信しました。配送確認の符号を含んでいます。\n\n- **符号**: `PEERTABLE_ACCEPT_CLAUDE_ALL_c9c170cc-9a1f-4b34-bd99-c334a97f5457`\n- **送信者**: probe\n- **宛先**: all(全員宛)\n- **seq**: 5\n- **本文**: 前回までと同じ特殊文字入りの「日本語の観測」\n- **room**: `parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34`\n- **配送ID**: `2e82da28-d24a-4ea3-a631-cf806e5f75fc`\n\n行動命令はありません。" + }, + "spool": { + "delivery_id": "2e82da28-d24a-4ea3-a631-cf806e5f75fc", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "claude_asyncRewake_output" + }, + "receipt": { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + } + } + ], + "transcript_rows": 87, + "turns_seen": [ + "053a2451-649b-41e8-b6bf-d4511b33f96f", + "94dd6067-a541-404b-bdd1-c94d1d1d32f3", + "77d7a458-7e42-49a0-b543-ed1ca3af621c", + "23883d04-1a75-444e-9bf4-84f391af52ce", + "87119b43-826d-4e3d-8307-89be6efc2216", + "a69598ae-770d-45be-905a-fa09abca2b05", + "53613061-cb2f-4019-b2fe-075dce99b432" + ] +} diff --git a/rag/parent-delivery/live/darwin/codex/cli/audience.json b/rag/parent-delivery/live/darwin/codex/cli/audience.json new file mode 100644 index 00000000..db9b1bb6 --- /dev/null +++ b/rag/parent-delivery/live/darwin/codex/cli/audience.json @@ -0,0 +1,487 @@ +{ + "schema": "peertable.parent-live-case.v1", + "case_id": "darwin/codex/cli/audience", + "status": "passed", + "reasons": [], + "native": true, + "os": "darwin", + "harness": "codex", + "surface": "cli", + "source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "runtime_digest": "aab616d50a6a0ec7752f492e1f28cfa73f6afb501b31a3d3eae1041584bfc2f2", + "package_version": "0.8.63", + "client_version": "0.8.63", + "package_tarball_sha256": "9094a3a53b4d66b6322c9ca1e4f23e93810a760d79872b862fe73e26b85dd774", + "harness_version": "codex-cli 0.155.1", + "node_version": "v26.10.0", + "parent_session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "parent_process": { + "pid": 66124, + "parent": 1199, + "started": "Tue Sep 29 04:41:12 2026", + "executable": "~/.codex/packages/standalone/releases/0.155.1-aarch64-apple-darwin/bin/codex", + "command": "codex -c mcp_servers.peertable_parent.env_vars=[\"PEERTABLE_TOKEN_SOURCE_FILE\"] -c check_for_update_on_startup=false -m gpt-6-sol" + }, + "endpoint_id": "f4029511-682a-4514-8b5a-f25335f59926", + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "owner_input_required": false, + "isolation": { + "home": "normal", + "room": "local_private_room_server", + "session_args": [ + "-c mcp_servers.peertable_parent.env_vars=[\"PEERTABLE_TOKEN_SOURCE_FILE\"]", + "-c check_for_update_on_startup=false" + ], + "folder_trust": "起動dialogで試験dirを信頼し、終了後に公式config/batchWriteでその projects entry だけを削除" + }, + "controller": { + "files_sha256": { + "README.md": "b388692fe0b584dd88ef73180bb9cebd5bf8e20b22c38a930251c1873c2a4a10", + "aiterm.mjs": "c3fa88a1032455dd70698e567d0e364d4714a6c3afd96cff46d5aad6ab953b16", + "background-harness.mjs": "40614a98000ec761dc38c6ba54a114c744db450d7efa7bc67a1276ab9a5ddca0", + "evidence.mjs": "216a4667764bcd7b7c402e2d81353f5c3cba200f20a303b1b1abb80735280490", + "harness.mjs": "282d2bf7d8aff8493a05eaef9903e2eadda85e3d4ef45a7762242d0f209f3712", + "run.mjs": "1b0aad3d3be8bf2a430c6469559b3d00bd579ed6d8e0bb4eb3582a5e4b4fc1ac", + "runner-provenance.mjs": "ec44651161c6f27bad824c75102a299c0ce6502b8ab6b7f640114f6488e4ad74", + "scenarios-context.mjs": "aa569402743406df39d9ff4d0bdf50fe7dcbd1c4ff3f2dd1858be5c44aa403e5", + "scenarios-fixtures.mjs": "f4ad05cafcede3132c322fee4f768626b97275e051219cc70241a0286a1e0bf8", + "scenarios-lease-run.mjs": "6348a310b1f56a350eb6c38c3bcd6b729bef369da408229a1a837a55f024becc", + "scenarios-native.mjs": "cce9267bc404b41c89e56608888086b3efb4aa1ff89af934f510358dd41b42fe", + "scenarios-surfaces.mjs": "bf5fab85bbdb8d50f4182d0db635779c0ced280eb0fda7ddcb28423da5906ea8", + "scenarios.mjs": "08db776c879b02b8da7b6263511256b192d35a6d78fdaf1164e05328184fca98" + } + }, + "runner": { + "commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "modules_sha256": { + "README.md": "b388692fe0b584dd88ef73180bb9cebd5bf8e20b22c38a930251c1873c2a4a10", + "aiterm.mjs": "c3fa88a1032455dd70698e567d0e364d4714a6c3afd96cff46d5aad6ab953b16", + "background-harness.mjs": "40614a98000ec761dc38c6ba54a114c744db450d7efa7bc67a1276ab9a5ddca0", + "evidence.mjs": "216a4667764bcd7b7c402e2d81353f5c3cba200f20a303b1b1abb80735280490", + "harness.mjs": "282d2bf7d8aff8493a05eaef9903e2eadda85e3d4ef45a7762242d0f209f3712", + "run.mjs": "1b0aad3d3be8bf2a430c6469559b3d00bd579ed6d8e0bb4eb3582a5e4b4fc1ac", + "runner-provenance.mjs": "ec44651161c6f27bad824c75102a299c0ce6502b8ab6b7f640114f6488e4ad74", + "scenarios-context.mjs": "aa569402743406df39d9ff4d0bdf50fe7dcbd1c4ff3f2dd1858be5c44aa403e5", + "scenarios-fixtures.mjs": "f4ad05cafcede3132c322fee4f768626b97275e051219cc70241a0286a1e0bf8", + "scenarios-lease-run.mjs": "6348a310b1f56a350eb6c38c3bcd6b729bef369da408229a1a837a55f024becc", + "scenarios-native.mjs": "cce9267bc404b41c89e56608888086b3efb4aa1ff89af934f510358dd41b42fe", + "scenarios-surfaces.mjs": "bf5fab85bbdb8d50f4182d0db635779c0ced280eb0fda7ddcb28423da5906ea8", + "scenarios.mjs": "08db776c879b02b8da7b6263511256b192d35a6d78fdaf1164e05328184fca98" + }, + "product_source_is_separate": true + }, + "observations": [ + { + "kind": "parent_join", + "parent_session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "turn_id": "01a0e989-44fe-7131-80ca-8607061db73f", + "tool_use_id": "2", + "tool_name": "output", + "endpoint_id": "f4029511-682a-4514-8b5a-f25335f59926", + "caller_owner": { + "pid": 66124, + "parent": 1199, + "started": "Tue Sep 29 04:41:12 2026", + "executable": "~/.codex/packages/standalone/releases/0.155.1-aarch64-apple-darwin/bin/codex", + "command": "codex -c mcp_servers.peertable_parent.env_vars=[\"PEERTABLE_TOKEN_SOURCE_FILE\"] -c check_for_update_on_startup=false -m gpt-6-sol" + }, + "call_generation": null + }, + { + "kind": "delivery_in_parent_conversation", + "label": "dm", + "parent_session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "turn_id": "01a0e989-44fe-7131-80ca-8607061db73f", + "seq": 3, + "delivery_id": "e3df1dd7-c145-442e-83a4-440fbef4aaaf", + "entry": { + "uuid": null, + "type": "response_item:message:user", + "role": "user" + }, + "native_hook": "stop", + "boundary": { + "encoding": "codex_hook_prompt_xml_text", + "raw_body_equal": false + }, + "native_queue": [], + "native_ack_id": "01a0e989-7441-7951-ab4f-19dc19832d73" + }, + { + "kind": "parent_reply_after_delivery", + "label": "dm", + "parent_session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "turn_id": "01a0e989-44fe-7131-80ca-8607061db73f", + "seq": 3, + "entry_uuid": null + }, + { + "kind": "delivery_in_parent_conversation", + "label": "multiple", + "parent_session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "turn_id": "01a0e989-bb2a-7580-bb6e-0c985c492b3f", + "seq": 4, + "delivery_id": "5acdfa56-7a2a-4d52-84c2-7ee5966f1fa4", + "entry": { + "uuid": null, + "type": "response_item:message:user", + "role": "user" + }, + "native_hook": null, + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "native_queue": [], + "native_ack_id": "01a0e989-b2f5-7c40-94e3-fd1867db1113" + }, + { + "kind": "parent_reply_after_delivery", + "label": "multiple", + "parent_session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "turn_id": "01a0e989-bb2a-7580-bb6e-0c985c492b3f", + "seq": 4, + "entry_uuid": null + }, + { + "kind": "delivery_in_parent_conversation", + "label": "all", + "parent_session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "turn_id": "01a0e989-e247-7713-b3a7-097cca48d1cf", + "seq": 5, + "delivery_id": "0be9d2d6-be47-4946-a59e-551bd54c0ccf", + "entry": { + "uuid": null, + "type": "response_item:message:user", + "role": "user" + }, + "native_hook": null, + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "native_queue": [], + "native_ack_id": "01a0e989-d277-75e1-ab0b-a63e86bec62f" + }, + { + "kind": "parent_reply_after_delivery", + "label": "all", + "parent_session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "turn_id": "01a0e989-e247-7713-b3a7-097cca48d1cf", + "seq": 5, + "entry_uuid": null + } + ], + "receipts": [ + { + "seq": 3, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "codex_hook_output_complete", + "receipt_revision": 2, + "queued_submission_id": "01a0e989-7441-7951-ab4f-19dc19832d73", + "accepted_at": "2026-09-28T19:41:34.658Z" + }, + { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "codex_queue_accepted", + "receipt_revision": 1, + "queued_submission_id": "01a0e989-b2f5-7c40-94e3-fd1867db1113", + "accepted_at": "2026-09-28T19:41:50.710Z" + }, + { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "codex_queue_accepted", + "receipt_revision": 1, + "queued_submission_id": "01a0e989-d277-75e1-ab0b-a63e86bec62f", + "accepted_at": "2026-09-28T19:41:58.776Z" + } + ], + "body_checks": [ + { + "label": "dm", + "original": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_DM_aac57c03-4a45-456b-8144-3ce1b2a1f51e です。" + }, + "received": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_DM_aac57c03-4a45-456b-8144-3ce1b2a1f51e です。" + } + }, + { + "label": "multiple", + "original": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_MULTIPLE_2531781f-bc9e-45a1-a995-ee32615ace9e です。" + }, + "received": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_MULTIPLE_2531781f-bc9e-45a1-a995-ee32615ace9e です。" + } + }, + { + "label": "all", + "original": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_ALL_d699f185-0e05-4f1a-b060-d22fcdc393b3 です。" + }, + "received": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_ALL_d699f185-0e05-4f1a-b060-d22fcdc393b3 です。" + } + } + ], + "checks": [ + { + "status": "passed", + "reasons": [], + "label": "dm", + "recipient": "bell", + "original": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_DM_aac57c03-4a45-456b-8144-3ce1b2a1f51e です。" + }, + "received": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_DM_aac57c03-4a45-456b-8144-3ce1b2a1f51e です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_DM_aac57c03-4a45-456b-8144-3ce1b2a1f51e です。", + "delivery_id": "e3df1dd7-c145-442e-83a4-440fbef4aaaf", + "digest": "cc9222d244a619511dc42ddcea5d2743de54b4bc8755d5072c3cec4c55040081", + "preview": false, + "order": 38, + "session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "turn_id": "01a0e989-44fe-7131-80ca-8607061db73f", + "hook": "stop", + "boundary": { + "encoding": "codex_hook_prompt_xml_text", + "raw_body_equal": false + }, + "entry": { + "uuid": null, + "type": "response_item:message:user", + "role": "user" + } + }, + "reply": { + "order": 41, + "session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "turn_id": "01a0e989-44fe-7131-80ca-8607061db73f", + "text": "- 配送確認の符号:`PEERTABLE_ACCEPT_CODEX_DM_aac57c03-4a45-456b-8144-3ce1b2a1f51e`\n- 送信者:`probe`\n- 宛先:`bell`\n- seq:`3`" + }, + "spool": { + "delivery_id": "e3df1dd7-c145-442e-83a4-440fbef4aaaf", + "state": "submitted", + "queued_submission_id": "01a0e989-7441-7951-ab4f-19dc19832d73", + "accepted_at": "2026-09-28T19:41:34.658Z", + "reason": "codex_hook_output_complete" + }, + "receipt": { + "seq": 3, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "codex_hook_output_complete", + "receipt_revision": 2, + "queued_submission_id": "01a0e989-7441-7951-ab4f-19dc19832d73", + "accepted_at": "2026-09-28T19:41:34.658Z" + } + }, + { + "status": "passed", + "reasons": [], + "label": "multiple", + "recipient": "bell", + "original": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_MULTIPLE_2531781f-bc9e-45a1-a995-ee32615ace9e です。" + }, + "received": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_MULTIPLE_2531781f-bc9e-45a1-a995-ee32615ace9e です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_MULTIPLE_2531781f-bc9e-45a1-a995-ee32615ace9e です。", + "delivery_id": "5acdfa56-7a2a-4d52-84c2-7ee5966f1fa4", + "digest": "2c74496deab155b53e5b65e1335d4cd2d1e341af00c31081247c63708f5d7975", + "preview": false, + "order": 47, + "session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "turn_id": "01a0e989-bb2a-7580-bb6e-0c985c492b3f", + "hook": null, + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "entry": { + "uuid": null, + "type": "response_item:message:user", + "role": "user" + } + }, + "reply": { + "order": 50, + "session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "turn_id": "01a0e989-bb2a-7580-bb6e-0c985c492b3f", + "text": "- 配送確認の符号:`PEERTABLE_ACCEPT_CODEX_MULTIPLE_2531781f-bc9e-45a1-a995-ee32615ace9e`\n- 送信者:`probe`\n- 宛先:`[\"bell\",\"rui\"]`\n- seq:`4`" + }, + "spool": { + "delivery_id": "5acdfa56-7a2a-4d52-84c2-7ee5966f1fa4", + "state": "submitted", + "queued_submission_id": "01a0e989-b2f5-7c40-94e3-fd1867db1113", + "accepted_at": "2026-09-28T19:41:50.710Z", + "reason": "codex_queue_accepted" + }, + "receipt": { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "codex_queue_accepted", + "receipt_revision": 1, + "queued_submission_id": "01a0e989-b2f5-7c40-94e3-fd1867db1113", + "accepted_at": "2026-09-28T19:41:50.710Z" + } + }, + { + "status": "passed", + "reasons": [], + "label": "all", + "recipient": "bell", + "original": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_ALL_d699f185-0e05-4f1a-b060-d22fcdc393b3 です。" + }, + "received": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_ALL_d699f185-0e05-4f1a-b060-d22fcdc393b3 です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_ALL_d699f185-0e05-4f1a-b060-d22fcdc393b3 です。", + "delivery_id": "0be9d2d6-be47-4946-a59e-551bd54c0ccf", + "digest": "99822f5837a808110166912457083810615f80f2fac3ff29f442a98d5177b548", + "preview": false, + "order": 56, + "session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "turn_id": "01a0e989-e247-7713-b3a7-097cca48d1cf", + "hook": null, + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "entry": { + "uuid": null, + "type": "response_item:message:user", + "role": "user" + } + }, + "reply": { + "order": 59, + "session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "turn_id": "01a0e989-e247-7713-b3a7-097cca48d1cf", + "text": "- 配送確認の符号:`PEERTABLE_ACCEPT_CODEX_ALL_d699f185-0e05-4f1a-b060-d22fcdc393b3`\n- 送信者:`probe`\n- 宛先:`all`\n- seq:`5`" + }, + "spool": { + "delivery_id": "0be9d2d6-be47-4946-a59e-551bd54c0ccf", + "state": "submitted", + "queued_submission_id": "01a0e989-d277-75e1-ab0b-a63e86bec62f", + "accepted_at": "2026-09-28T19:41:58.776Z", + "reason": "codex_queue_accepted" + }, + "receipt": { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "codex_queue_accepted", + "receipt_revision": 1, + "queued_submission_id": "01a0e989-d277-75e1-ab0b-a63e86bec62f", + "accepted_at": "2026-09-28T19:41:58.776Z" + } + } + ], + "transcript_rows": 63, + "turns_seen": [ + "01a0e989-44fe-7131-80ca-8607061db73f", + "01a0e989-bb2a-7580-bb6e-0c985c492b3f", + "01a0e989-e247-7713-b3a7-097cca48d1cf" + ] +} diff --git a/rag/parent-delivery/product-acceptance.json b/rag/parent-delivery/product-acceptance.json index 1f6e357d..37481dd3 100644 --- a/rag/parent-delivery/product-acceptance.json +++ b/rag/parent-delivery/product-acceptance.json @@ -1,4 +1,87 @@ { "schema": "peertable.parent-product-acceptance.v1", - "records": [] + "records": [ + { + "id": "darwin/claude/cli/audience", + "status": "passed", + "kind": "product_live", + "native": true, + "harness_version": "2.1.284 (Claude Code)", + "source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "runtime_digest": "aab616d50a6a0ec7752f492e1f28cfa73f6afb501b31a3d3eae1041584bfc2f2", + "package_version": "0.8.63", + "parent_session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "evidence_file": "rag/parent-delivery/live/darwin/claude/cli/audience.json", + "body_equal": true, + "owner_input_required": false, + "receipt_evidence": "3:bell:r1,4:bell:r1,5:bell:r1", + "observations": [ + "parent_join", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery" + ], + "audiences": { + "dm": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "multiple": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "all": { + "count": 1, + "body_equal": true, + "status": "passed" + } + } + }, + { + "id": "darwin/codex/cli/audience", + "status": "passed", + "kind": "product_live", + "native": true, + "harness_version": "codex-cli 0.155.1", + "source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "runtime_digest": "aab616d50a6a0ec7752f492e1f28cfa73f6afb501b31a3d3eae1041584bfc2f2", + "package_version": "0.8.63", + "parent_session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "evidence_file": "rag/parent-delivery/live/darwin/codex/cli/audience.json", + "body_equal": true, + "owner_input_required": false, + "receipt_evidence": "3:bell:r2,4:bell:r1,5:bell:r1", + "observations": [ + "parent_join", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery" + ], + "audiences": { + "dm": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "multiple": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "all": { + "count": 1, + "body_equal": true, + "status": "passed" + } + } + } + ] } diff --git a/rag/parent-delivery/release-candidate-checkpoint.json b/rag/parent-delivery/release-candidate-checkpoint.json index 39707111..264ccb99 100644 --- a/rag/parent-delivery/release-candidate-checkpoint.json +++ b/rag/parent-delivery/release-candidate-checkpoint.json @@ -33,9 +33,9 @@ "manifest": "rag/parent-delivery/product-acceptance.json", "status": "failed", "expected": 525, - "records": 0, + "records": 2, "errors": { - "PARENT_ACCEPTANCE_MISSING": 525 + "PARENT_ACCEPTANCE_MISSING": 523 }, "checkpoint_counts_as_live_acceptance": false }, From 1d0111f7fd39ad82abc506e98cc4bc4c264ff61b Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:04:09 +0900 Subject: [PATCH 40/72] =?UTF-8?q?=E5=90=8C=E6=9C=9Fprehook=E3=81=A8?= =?UTF-8?q?=E5=AE=9FMCP=E3=81=AE=E6=9D=9F=E7=B8=9B=E6=9C=9F=E9=99=90?= =?UTF-8?q?=E3=82=92=E6=89=80=E6=9C=89context=E3=81=A7=E8=A6=B3=E6=B8=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scenarios-fixtures.mjs | 21 ++++-- .../scenarios-fixtures.test.mjs | 32 ++++++++- .../scenarios-native.mjs | 70 ++++++++++++++++++- .../scenarios-native.test.mjs | 42 ++++++++++- 4 files changed, 155 insertions(+), 10 deletions(-) diff --git a/experiments/parent-product-acceptance/scenarios-fixtures.mjs b/experiments/parent-product-acceptance/scenarios-fixtures.mjs index 34b3bb52..6554fc34 100644 --- a/experiments/parent-product-acceptance/scenarios-fixtures.mjs +++ b/experiments/parent-product-acceptance/scenarios-fixtures.mjs @@ -19,7 +19,14 @@ const writeJson = (file, value) => { mkdirSync(dirname(file), { recursive: true, // observer本人が起動時identityを保存する。終了後にPIDだけから本人を取り直さない。 export function nativeHookObserverSource(platformEntry) { - return `import {processIdentity} from ${JSON.stringify(platformEntry)};import {readFileSync,appendFileSync,existsSync} from 'node:fs';const owner=processIdentity(process.pid);let raw='';for await(const chunk of process.stdin)raw+=chunk;const event=JSON.parse(raw.replace(/^\\uFEFF/u,''));appendFileSync(process.argv[2],JSON.stringify({at:new Date().toISOString(),pid:process.pid,owner,event})+'\\n');if(existsSync(process.argv[3])){const control=JSON.parse(readFileSync(process.argv[3],'utf8'));if(control.hold){while(!existsSync(control.release))await new Promise(r=>setTimeout(r,25));}}process.stdout.write('{}\\n');\n` + return `import {processIdentity} from ${JSON.stringify(platformEntry)};import {readFileSync,appendFileSync,existsSync} from 'node:fs';const owner=processIdentity(process.pid);let raw='';for await(const chunk of process.stdin)raw+=chunk;const event=JSON.parse(raw.replace(/^\\uFEFF/u,''));const control=existsSync(process.argv[3])?JSON.parse(readFileSync(process.argv[3],'utf8')):null;const name=event.tool_name??event.toolName??'';const operation=/parent_(join|read|leave)$/.exec(name)?.[0];const held=Boolean(control?.hold&&(!control.onlyTool||operation===control.onlyTool)&&(!control.onlyEvent||event.hook_event_name===control.onlyEvent));appendFileSync(process.argv[2],JSON.stringify({at:new Date().toISOString(),pid:process.pid,owner,event,held,control_release:held?control.release:null})+'\\n');if(held)while(!existsSync(control.release))await new Promise(r=>setTimeout(r,25));process.stdout.write('{}\\n');\n` +} + +// Grokの直列gateで製品prehookの完了後にだけ待つ。共有global hookには触れない。 +export function appendGrokProductObserver(value, event, observer) { + if (value.hooks[event]?.length !== 1 || value.hooks[event][0].hooks?.length !== 1) fail('ACCEPTANCE_PROJECT_PREHOOK_ORDER_UNCONFIRMED', '専用Grok製品handler 1個の直後へobserverを追加できません') + value.hooks[event][0].hooks.push(observer) + return value } export function hookConfigurationSnapshot(hooks) { @@ -182,16 +189,22 @@ export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUr configuration.assertHooksUnchanged() return { fault, local_config: local, before: owned, after: affected, official_method: 'hooks/list', global_sourcePath: configuration.hooks, normal_hook_bytes_equal: true } } - fixture.addObserver = async ({ events, additionalArgs = [] } = {}) => { + fixture.addObserver = async ({ events, additionalArgs = [], appendToProductFile = false } = {}) => { const observer = join(project, 'acceptance-hook.mjs'), observations = join(directory, 'official-hook-events.jsonl'), controls = join(directory, 'hook-controls.json') writeFileSync(observer, nativeHookObserverSource(pathToFileURL(join(pkg, 'skill/scripts/parent-platform.mjs')).href), { mode: 0o600 }) const command = platform.hookCommand(process.execPath, [observer, observations, controls, ...additionalArgs]) if (!['claude', 'codex'].includes(harness) && !adapter.projectHookFile) fail('ACCEPTANCE_PROJECT_HOOK_PATH_UNCONFIRMED', `${harness}: 公式のproject hook配置が実測されていません`) - const file = harness === 'claude' ? join(project, '.claude/settings.local.json') : harness === 'codex' ? join(project, '.codex/hooks.json') : adapter.projectHookFile(project) + const file = harness === 'claude' ? join(project, '.claude/settings.local.json') : harness === 'codex' ? join(project, '.codex/hooks.json') : appendToProductFile && harness === 'grok' ? join(project, '.grok/hooks/peertable-parent.json') : adapter.projectHookFile(project) const nativeEvents = events ?? (harness === 'cursor' ? ['preToolUse', 'postToolUse', 'afterMCPExecution'] : ['PreToolUse', 'PostToolUse', 'Stop']) fixture.modify(file, before => { const value = before ? JSON.parse(before.toString('utf8').replace(/^\uFEFF/u, '')) : { ...(harness === 'cursor' ? { version: 1 } : {}), hooks: {} } - for (const event of nativeEvents) { const entry = harness === 'cursor' ? { command, timeout: 86400 } : { hooks: [{ type: 'command', command, timeout: 86400 }] }; value.hooks[event] = [...(value.hooks[event] ?? []), entry] } + for (const event of nativeEvents) { + const entry = harness === 'cursor' ? { command, timeout: 86400 } : { hooks: [{ type: 'command', command, timeout: 86400 }] } + if (appendToProductFile) { + if (harness !== 'grok') fail('ACCEPTANCE_PROJECT_PREHOOK_ORDER_UNCONFIRMED', '直列製品handlerへの追記は専用Grokだけです') + appendGrokProductObserver(value, event, entry.hooks[0]) + } else value.hooks[event] = [...(value.hooks[event] ?? []), entry] + } return Buffer.from(JSON.stringify(value, null, 2) + '\n') }) if (harness === 'codex') await fixture.trustProjectHooks() diff --git a/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs b/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs index 3fa5fdf1..740ed1cf 100644 --- a/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs +++ b/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs @@ -7,7 +7,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { processIdentity, sameProcess } from '../../skill/scripts/parent-platform.mjs' -import { assertOnlyProjectTrustChanged, hookConfigurationSnapshot, waitOwnedFixtureExit, nativeHookObserverSource } from './scenarios-fixtures.mjs' +import { assertOnlyProjectTrustChanged, hookConfigurationSnapshot, waitOwnedFixtureExit, nativeHookObserverSource, appendGrokProductObserver } from './scenarios-fixtures.mjs' test('fixture終了は実child消失の後も製品自己停止と索引撤去を待つ', async t => { const child = spawn(process.execPath, ['-e', 'setTimeout(()=>process.exit(0),150)'], { stdio: 'ignore' }) @@ -72,3 +72,33 @@ test('公式hook observerは起動時PID/startを保存し、解除後の本人 assert.equal(sameProcess(row.owner), true); assert.equal(sameProcess({ ...row.owner, started: '同PIDの別起動' }), false) writeFileSync(release, '{}'); assert.equal(await ended, 0); assert.equal(sameProcess(row.owner), false) }) + + +test('専用Grokの直列prehookは製品handler直後へ追加し、他eventの原形を保つ', t => { + const directory = mkdtempSync(join(tmpdir(), 'peertable-grok-hook-order-')); t.after(() => rmSync(directory, { recursive: true, force: true })) + const file = join(directory, 'peertable-parent.json'), product = { type: 'command', command: 'own product prehook' }, observer = { type: 'command', command: 'own observer', timeout: 86400 }, other = [{ hooks: [{ type: 'command', command: 'own product posthook' }] }] + writeFileSync(file, JSON.stringify({ hooks: { PreToolUse: [{ matcher: 'mcp__peertable_parent__parent_join', hooks: [product] }], PostToolUse: other } })) + const value = appendGrokProductObserver(JSON.parse(readFileSync(file, 'utf8')), 'PreToolUse', observer); writeFileSync(file, JSON.stringify(value)) + const actual = JSON.parse(readFileSync(file, 'utf8')) + assert.deepEqual(actual.hooks.PreToolUse[0].hooks, [product, observer]); assert.deepEqual(actual.hooks.PostToolUse, other) + assert.throws(() => appendGrokProductObserver(actual, 'PreToolUse', observer), { code: 'ACCEPTANCE_PROJECT_PREHOOK_ORDER_UNCONFIRMED' }) +}) + +test('束縛observerは同eventのparent_joinだけを実processでholdする', async t => { + const directory = mkdtempSync(join(tmpdir(), 'peertable-binding-observer-')); t.after(() => rmSync(directory, { recursive: true, force: true })) + const script = join(directory, 'observer.mjs'), observed = join(directory, 'events.jsonl'), controls = join(directory, 'controls.json'), release = join(directory, 'release.json') + writeFileSync(script, nativeHookObserverSource(pathToFileURL(join(process.cwd(), 'skill/scripts/parent-platform.mjs')).href)) + writeFileSync(controls, JSON.stringify({ hold: true, onlyTool: 'parent_join', onlyEvent: 'PreToolUse', release })) + async function invoke(event) { + const child = spawn(process.execPath, [script, observed, controls], { stdio: ['pipe', 'pipe', 'inherit'] }) + t.after(() => { if (child.exitCode === null) child.kill('SIGKILL') }); const ended = new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) }); child.stdin.end(JSON.stringify(event)); return { child, ended } + } + for (const event of [{ hook_event_name: 'PreToolUse', tool_name: 'mcp__peertable_parent__parent_read' }, { hook_event_name: 'PostToolUse', tool_name: 'mcp__peertable_parent__parent_join' }]) { + const call = await invoke(event); assert.equal(await call.ended, 0) + } + const call = await invoke({ hook_event_name: 'PreToolUse', toolName: 'mcp__peertable_parent__parent_join' }) + let entries = []; const deadline = Date.now() + 3000 + while (entries.length < 3) { try { entries = readFileSync(observed, 'utf8').trim().split('\n').map(JSON.parse) } catch (error) { if (error.code !== 'ENOENT') throw error }; assert.ok(Date.now() < deadline); if (entries.length < 3) await new Promise(resolve => setTimeout(resolve, 20)) } + assert.deepEqual(entries.map(row => row.held), [false, false, true]); assert.equal(call.child.exitCode, null); assert.equal(entries[2].control_release, release); assert.equal(sameProcess(entries[2].owner), true) + writeFileSync(release, '{}'); assert.equal(await call.ended, 0); assert.equal(sameProcess(entries[2].owner), false) +}) diff --git a/experiments/parent-product-acceptance/scenarios-native.mjs b/experiments/parent-product-acceptance/scenarios-native.mjs index f0ecfbca..f575e210 100644 --- a/experiments/parent-product-acceptance/scenarios-native.mjs +++ b/experiments/parent-product-acceptance/scenarios-native.mjs @@ -69,11 +69,21 @@ export function productToolError(value, expected) { if (typeof value === 'string') { try { return productToolError(JSON.parse(value), expected) } catch (error) { if (error instanceof SyntaxError) return null; throw error } } if (!value || typeof value !== 'object') return null if (value.schema === 'peertable.parent-error.v1') return value.state === 'failed' && value.error_code === expected ? value : null + if (value.type === 'MCP' && value.tool_name === 'parent_join') return productToolError(value.output?.OkayOutput, expected) if (value.structuredContent) return productToolError(value.structuredContent, expected) for (const part of value.content ?? []) if (part.type === 'text') { const found = productToolError(part.text, expected); if (found) return found } return null } +// 実prehookが作った原bytesと相関を読む。期限や所有者をfixtureで補正しない。 +export function bindingContextProof({ file, harness, parentSession, parentProcess, event, digest, sameProcess }) { + if (!existsSync(file)) return null + const raw = readFileSync(file), value = JSON.parse(raw.toString('utf8')) + const use = event.tool_use_id ?? event.toolUseId, input = harness === 'grok' ? event.toolInput?.tool_input : event.tool_input + if (value.harness !== harness || value.conversation !== parentSession || value.use !== use || value.name !== 'parent_join' || value.input_digest !== digest(input) || value.owner?.pid !== parentProcess.pid || value.owner?.started !== parentProcess.started || !Number.isFinite(value.created_at) || !sameProcess(value.owner)) fail('ACCEPTANCE_BIND_CONTEXT_OWNER', '未消費contextが同じtool入力/会話/親本人と一致しません') + return { path: file, value, sha256: sha256(raw) } +} + // callbackで判定を置換しない。実hookの同eventを実process 2個以上が受け、本文出力が1個であることを照合する。 export function competitorProof(events, { session, delivery, minimum = 2, compatibility = false }) { const groups = new Map() @@ -532,9 +542,61 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { } actions.hook_restore = async (_, s) => { fixtureFor(self(s)).configuration.assertHooksUnchanged(); return s.result(s, 'normal_launch_intact', 'official_hook', { configuration_unchanged: true }) } - // 期限は製品の実bind/probe境界で待つ。保存stateへ時刻を代入するfixtureは受入へ使わない。 - for (const action of ['binding_timeout_arm', 'binding_timeout_observe', 'probe_timeout_arm', 'probe_timeout_observe', 'deadline_restore']) { - actions[action] = async (_, s) => { sourceUnconfirmed(s.harness, '公式MCP相関を保持した専用binding/probe遮断', { source: join(s.pkg, 'skill/scripts/parent-caller.mjs'), probe_source: join(s.pkg, 'skill/scripts/parent-watch.mjs'), parent_session: self(s).meta.parent_session }) } + // bind期限は製品が作ったcontextを読取り、同期hookとMCPの間を実時間で跨がせる。 + actions.binding_timeout_arm = async (_, s) => { + if (s.harness === 'codex') sourceUnconfirmed(s.harness, '同期official metadataの束縛とhook期限の条件付き契約', { source: join(s.pkg, 'skill/scripts/parent-caller.mjs'), binding_pending_exists: false }) + const target = self(s), fixture = fixtureFor(target), before = observerRows(fixture).length + if (!fixture.observer) sourceUnconfirmed(s.harness, '同期PreToolUseの専用observer') + const release = s.artifactFor(s, 'binding-hook-release'), eventName = s.harness === 'cursor' ? 'preToolUse' : 'PreToolUse' + state(s).deadline = { releases: [release], fixture, target, before, release } + writeFileSync(fixture.observer.controls, JSON.stringify({ hold: true, onlyTool: 'parent_join', onlyEvent: eventName, release })) + await fixture.submit(`Peertable parent_joinをproject=${fixture.project} name=${fixture.name}で1回だけ呼んでください。今回は公式hookとMCPの束縛期限を実測しています。終了結果のerror_codeをそのまま報告し、自動再試行しないでください。`) + const event = await s.until('実parent_joinの同期PreToolUse待機', () => observerRows(fixture).slice(before).find(row => row.held === true && row.control_release === release && row.event.hook_event_name === eventName && nativeSession(row.event) === target.meta.parent_session && /parent_join$/u.test(row.event.tool_name ?? row.event.toolName ?? '')), 120000, 100) + if (!event.owner?.started || !s.sameProcess(event.owner)) fail('ACCEPTANCE_BIND_OBSERVER_NOT_LIVE', '同期observerの実processがありません') + const { parentHome } = await import(pathToFileURL(join(s.pkg, 'skill/scripts/parent-platform.mjs')).href) + const { digest } = await import(pathToFileURL(join(s.pkg, 'skill/scripts/parent-delivery.mjs')).href) + const use = event.event.tool_use_id ?? event.event.toolUseId + const context = await s.until('実製品prehookによる未消費context作成', () => { + let key + if (s.harness === 'claude') key = digest(['claude', use]) + else { + const entry = fixture.adapter.hookEvents().find(row => row.event.hook_event_name === eventName && nativeSession(row.event) === target.meta.parent_session && (row.event.tool_use_id ?? row.event.toolUseId) === use) + if (!entry) return null + const output = JSON.parse(entry.stdout) + key = s.harness === 'cursor' ? output.updated_input?.hook_context_id : output.hookSpecificOutput?.updatedInput?.tool_input?.hook_context_id + if (!key) fail('ACCEPTANCE_BIND_CONTEXT_RESULT_MISSING', '実製品prehookのcontext IDがありません') + } + if (!/^(?:[0-9a-f]{64}|[0-9a-f-]{36})$/u.test(key)) fail('ACCEPTANCE_BIND_CONTEXT_KEY_INVALID', '製品prehookのcontext ID形式が不一致です') + return bindingContextProof({ file: join(parentHome(), 'contexts', `${key}.json`), harness: s.harness, parentSession: target.meta.parent_session, parentProcess: target.meta.parent_process, event: event.event, digest, sameProcess: s.sameProcess }) + }, 15000, 50) + Object.assign(state(s).deadline, { event, context }) + return s.result(s, 'own_binding_pending', 'official_hook', { phase: '製品prehook context生成後、公式MCP呼出し前', existing_endpoint_state: target.spool.read().state, context: context.value, context_sha256: context.sha256, observer_owner: event.owner, observer_artifact: fixture.observer.observations, context_modified: false }) + } + actions.binding_timeout_observe = async (_, s) => { + const pending = state(s).deadline + if (!pending?.context) fail('ACCEPTANCE_BIND_CONTEXT_MISSING', '実束縛contextがありません') + await s.until('製品contextの実時間35秒経過', () => { + if (!s.sameProcess(pending.event.owner) || !existsSync(pending.context.path) || sha256(readFileSync(pending.context.path)) !== pending.context.sha256) fail('ACCEPTANCE_BIND_CONTEXT_CHANGED', '待機中のown observer/contextが変化しました') + return Date.now() - pending.context.value.created_at >= 35000 + }, 45000, 100) + const releasedAt = new Date().toISOString(); writeFileSync(pending.release, '{}') + const observed = await s.until('実MCPのPARENT_BIND_TIMEOUT', () => { + const seen = pending.target.observe() + for (const use of seen.toolUses.filter(use => use.name === 'parent_join' && use.id === pending.context.value.use)) { + const output = use.output ?? seen.toolUses.find(row => row.name === 'output' && row.id === use.id && row.order > use.order)?.output + const error = productToolError(output, 'PARENT_BIND_TIMEOUT') + if (error) return { use, output, error } + } + return null + }, 120000, 100) + return s.result(s, 'binding_timeout_typed_failed', 'harness_transcript', { tool_use_id: observed.use.id, error_code: observed.error.error_code, official_tool_output: observed.output, context_created_at: pending.context.value.created_at, released_at: releasedAt, elapsed_before_mcp_ms: Date.parse(releasedAt) - pending.context.value.created_at, observer_owner: pending.event.owner, endpoint_state_after_prejoin_failure: pending.target.spool.read().state, context_modified: false }) + } + for (const action of ['probe_timeout_arm', 'probe_timeout_observe']) actions[action] = async (_, s) => sourceUnconfirmed(s.harness, '専用probe未消費を製品の実期限まで保持する操作', { source: join(s.pkg, 'skill/scripts/parent-watch.mjs'), parent_session: self(s).meta.parent_session }) + actions.deadline_restore = async (_, s) => { + const pending = state(s).deadline + if (pending) for (const release of pending.releases) writeFileSync(release, '{}') + const target = await join(s) + return s.result(s, 'new_join_verified', 'harness_transcript', { parent_session: target.meta.parent_session, endpoint_id: target.spool.id, state: target.spool.read().state }) } const lifecycleOperation = async (s, mode) => { @@ -587,6 +649,7 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { const errors = [] try { const current = saved.get(s.runId) + for (const release of current?.deadline?.releases ?? []) writeFileSync(release, '{}') if (current?.work) { writeFileSync(current.work.release, '{}'); if (current.work.owner) await waitOwnedFixtureExit({ readEndpoints: () => [], knownOwners: [current.work.owner], indexExists: () => false, sameProcess: s.sameProcess, timeout: 30000 }) } if (current?.final) { writeFileSync(current.final.release, '{}'); if (current.final.monitor) { current.final.monitor.canceled = true; await current.final.monitor.promise } } if (current?.recovery?.monitor) { current.recovery.monitor.canceled = true; await current.recovery.monitor.promise } @@ -617,6 +680,7 @@ export async function createNativeScenarioContext(name, options) { primary = await factory.open({ harness: options.sourceMeta.harness, name: `scenario-${name}-${randomUUID().slice(0, 8)}`, prepare: async fixture => { if (['final_race', 'session_change', 'foreign_ownership'].includes(name) && !(name === 'session_change' && ['cursor', 'grok'].includes(fixture.harness))) await fixture.addObserver({ events: fixture.harness === 'cursor' ? ['stop', 'postToolUse'] : name === 'session_change' ? ['SessionStart'] : ['Stop', 'PostToolUse'] }) if (fixture.harness === 'cursor' && ['idle', 'consecutive', 'no_external_tools'].includes(name)) await fixture.addObserver({ events: ['stop'] }) + if (name === 'binding_deadline' && fixture.harness !== 'codex') await fixture.addObserver({ events: [fixture.harness === 'cursor' ? 'preToolUse' : 'PreToolUse'], appendToProductFile: fixture.harness === 'grok' }) if (['claim_race', 'slot_race'].includes(name)) await fixture.addProductCompetitors({ count: 2 }) if (name === 'compatibility_hooks') await fixture.addProductCompetitors({ count: 1, compatibilityHarness: options.sourceMeta.harness === 'claude' ? 'codex' : 'claude' }) } }) diff --git a/experiments/parent-product-acceptance/scenarios-native.test.mjs b/experiments/parent-product-acceptance/scenarios-native.test.mjs index 59d24c12..910ff746 100644 --- a/experiments/parent-product-acceptance/scenarios-native.test.mjs +++ b/experiments/parent-product-acceptance/scenarios-native.test.mjs @@ -3,12 +3,13 @@ import test from 'node:test' import assert from 'node:assert/strict' import { spawn, execFileSync } from 'node:child_process' import { createInterface } from 'node:readline' -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { competitorProof, queueConnectionProof, monitorQueueConnections, createNativeActions, productToolError, assertOwnedReceiver, assertOwnedResume, pauseOwnedReceiver, nativeLeaseRegistration } from './scenarios-native.mjs' +import { competitorProof, queueConnectionProof, monitorQueueConnections, createNativeActions, productToolError, assertOwnedReceiver, assertOwnedResume, pauseOwnedReceiver, nativeLeaseRegistration, bindingContextProof } from './scenarios-native.mjs' import { processIdentity, sameProcess, processDescendsFrom } from '../../skill/scripts/parent-platform.mjs' import { scenarioPlan } from './scenarios.mjs' +import { digest } from '../../skill/scripts/parent-delivery.mjs' const event = { session_id: 'own', turn_id: 'turn', hook_event_name: 'PostToolUse', tool_use_id: 'tool' } const pair = (pid, value = event, stdout = '{}', code = 0) => [{ phase: 'started', pid, event: value }, { phase: 'completed', pid, event: value, stdout, code }] @@ -140,3 +141,40 @@ test('lease開始はverified後の未登録期間を待ち、公式tool/task登 current.state = 'failed'; current.error_code = '実製品error' assert.throws(() => nativeLeaseRegistration(target, options), { code: current.error_code }) }) + + +test('束縛contextは実file原bytesと同会話/tool入力/PID開始identityで照合する', t => { + const dir = mkdtempSync(join(tmpdir(), 'peertable-binding-context-')); t.after(() => rmSync(dir, { recursive: true, force: true })) + const file = join(dir, 'context.json'), owner = processIdentity(process.pid), input = { name: '専用親', project: '/専用 project' }, event = { tool_use_id: 'official-use', tool_input: input } + const value = { harness: 'claude', conversation: 'own-CID', use: event.tool_use_id, name: 'parent_join', input_digest: digest(input), owner, created_at: Date.now() } + const raw = Buffer.from(JSON.stringify(value, null, 2) + '\n'); writeFileSync(file, raw) + const options = { file, harness: 'claude', parentSession: value.conversation, parentProcess: owner, event, digest, sameProcess } + const proof = bindingContextProof(options) + assert.deepEqual(proof.value, value); assert.match(proof.sha256, /^[0-9a-f]{64}$/u); assert.deepEqual(readFileSync(file), raw) + for (const changed of [{ conversation: 'other-CID' }, { use: 'other-use' }, { input_digest: digest({ ...input, name: '別親' }) }, { owner: { ...owner, started: 'PID再利用' } }, { created_at: null }]) { + writeFileSync(file, JSON.stringify({ ...value, ...changed })); assert.throws(() => bindingContextProof(options), { code: 'ACCEPTANCE_BIND_CONTEXT_OWNER' }) + } + const grok = { ...value, harness: 'grok' }; writeFileSync(file, JSON.stringify(grok)) + assert.deepEqual(bindingContextProof({ ...options, harness: 'grok', event: { toolUseId: value.use, toolInput: { tool_input: input } } }).value, grok) + assert.throws(() => bindingContextProof({ ...options, harness: 'grok', event: { toolUseId: value.use, toolInput: input } })) + rmSync(file); assert.equal(bindingContextProof(options), null) +}) + +test('Grokの公式MCP包装だけから束縛期限errorを読み、説明文や別toolを拒否する', () => { + const error = { schema: 'peertable.parent-error.v1', state: 'failed', error_code: 'PARENT_BIND_TIMEOUT' } + const wrapped = { type: 'MCP', tool_name: 'parent_join', output: { OkayOutput: { structuredContent: error } } } + assert.deepEqual(productToolError(wrapped, error.error_code), error) + assert.equal(productToolError({ ...wrapped, tool_name: '無関係tool' }, error.error_code), null) + assert.equal(productToolError({ ...wrapped, output: { OkayOutput: 'PARENT_BIND_TIMEOUTと説明する自由文' } }, error.error_code), null) +}) + +test('束縛armが実event待ちで失敗しても私物holdを解除してfactoryを閉じる', async t => { + const dir = mkdtempSync(join(tmpdir(), 'peertable-binding-cleanup-')); t.after(() => rmSync(dir, { recursive: true, force: true })) + const release = join(dir, 'release'), fixture = { project: '/専用project', name: '専用親', observer: { controls: join(dir, 'controls'), observations: join(dir, 'events') }, submit: async () => {} } + const target = { fixture, meta: { parent_session: 'own' } }; let closed = false + const native = createNativeActions({ factory: { close: async () => { closed = true } }, primary: fixture }) + const scope = { harness: 'claude', runId: 'binding-run', context: { endpoint: () => target }, artifactFor: () => release, until: async () => { throw Object.assign(new Error('実event待ち失敗'), { code: 'TEST_BIND_EVENT_TIMEOUT' }) }, sameProcess: () => false } + await assert.rejects(native.actions.binding_timeout_arm({}, scope), { code: 'TEST_BIND_EVENT_TIMEOUT' }) + assert.equal(existsSync(release), false); await native.finalize(scope) + assert.equal(readFileSync(release, 'utf8'), '{}'); assert.equal(closed, true) +}) From 96c4f9ad36346b847a4e61a2e2f833c9112838eb Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:08:16 +0900 Subject: [PATCH 41/72] =?UTF-8?q?Claude=E6=9D=9F=E7=B8=9B=E6=9C=9F?= =?UTF-8?q?=E9=99=90=E3=81=AE=E5=AE=9Ftool=E7=B5=90=E6=9E=9C=E3=82=92?= =?UTF-8?q?=E5=90=8CID=E3=81=AE=E5=8E=9Ftranscript=E3=81=A7=E7=85=A7?= =?UTF-8?q?=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scenarios-native.mjs | 36 +++++++++++++++---- .../scenarios-native.test.mjs | 22 +++++++++++- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/experiments/parent-product-acceptance/scenarios-native.mjs b/experiments/parent-product-acceptance/scenarios-native.mjs index f575e210..cfa8fd0b 100644 --- a/experiments/parent-product-acceptance/scenarios-native.mjs +++ b/experiments/parent-product-acceptance/scenarios-native.mjs @@ -75,6 +75,34 @@ export function productToolError(value, expected) { return null } +// Claudeのtool_resultは既存observerのtoolUsesへ入らないため、対象IDだけを原transcriptで照合する。 +export function bindingToolError({ harness, file, session, useId, seen, expected }) { + const name = value => ['parent_join', 'mcp__peertable_parent__parent_join', 'peertable_parent__parent_join'].includes(value) + if (harness !== 'claude') { + for (const use of seen.toolUses.filter(use => name(use.name) && use.id === useId && use.session === session)) { + const output = use.output ?? seen.toolUses.find(row => row.name === 'output' && row.id === useId && row.order > use.order)?.output + const error = productToolError(output, expected) + if (error) return { use, output, error } + } + return null + } + const lines = readFileSync(file, 'utf8').split('\n'); lines.pop() + let call = null + for (const [order, line] of lines.entries()) { + let row + try { row = JSON.parse(line) } catch { fail('ACCEPTANCE_TRANSCRIPT_CORRUPT', `${file}:${order + 1} が確定行なのにJSONではありません`) } + if (row.sessionId !== session) continue + const parts = Array.isArray(row.message?.content) ? row.message.content : [] + if (row.type === 'assistant') for (const part of parts) if (part.type === 'tool_use' && part.id === useId && name(part.name)) call = { order, session, id: part.id, name: part.name, input: part.input, raw: part } + if (!call || row.type !== 'user' || order <= call.order) continue + for (const part of parts) if (part.type === 'tool_result' && part.tool_use_id === useId) { + const output = part.content, error = productToolError(typeof output === 'string' ? output : { content: output }, expected) + if (error) return { use: call, output: part, error, output_order: order, output_entry_uuid: row.uuid } + } + } + return null +} + // 実prehookが作った原bytesと相関を読む。期限や所有者をfixtureで補正しない。 export function bindingContextProof({ file, harness, parentSession, parentProcess, event, digest, sameProcess }) { if (!existsSync(file)) return null @@ -581,13 +609,7 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { }, 45000, 100) const releasedAt = new Date().toISOString(); writeFileSync(pending.release, '{}') const observed = await s.until('実MCPのPARENT_BIND_TIMEOUT', () => { - const seen = pending.target.observe() - for (const use of seen.toolUses.filter(use => use.name === 'parent_join' && use.id === pending.context.value.use)) { - const output = use.output ?? seen.toolUses.find(row => row.name === 'output' && row.id === use.id && row.order > use.order)?.output - const error = productToolError(output, 'PARENT_BIND_TIMEOUT') - if (error) return { use, output, error } - } - return null + return bindingToolError({ harness: s.harness, file: pending.target.file, session: pending.target.meta.parent_session, useId: pending.context.value.use, seen: s.harness === 'claude' ? null : pending.target.observe(), expected: 'PARENT_BIND_TIMEOUT' }) }, 120000, 100) return s.result(s, 'binding_timeout_typed_failed', 'harness_transcript', { tool_use_id: observed.use.id, error_code: observed.error.error_code, official_tool_output: observed.output, context_created_at: pending.context.value.created_at, released_at: releasedAt, elapsed_before_mcp_ms: Date.parse(releasedAt) - pending.context.value.created_at, observer_owner: pending.event.owner, endpoint_state_after_prejoin_failure: pending.target.spool.read().state, context_modified: false }) } diff --git a/experiments/parent-product-acceptance/scenarios-native.test.mjs b/experiments/parent-product-acceptance/scenarios-native.test.mjs index 910ff746..e2857566 100644 --- a/experiments/parent-product-acceptance/scenarios-native.test.mjs +++ b/experiments/parent-product-acceptance/scenarios-native.test.mjs @@ -6,7 +6,7 @@ import { createInterface } from 'node:readline' import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { competitorProof, queueConnectionProof, monitorQueueConnections, createNativeActions, productToolError, assertOwnedReceiver, assertOwnedResume, pauseOwnedReceiver, nativeLeaseRegistration, bindingContextProof } from './scenarios-native.mjs' +import { competitorProof, queueConnectionProof, monitorQueueConnections, createNativeActions, productToolError, assertOwnedReceiver, assertOwnedResume, pauseOwnedReceiver, nativeLeaseRegistration, bindingContextProof, bindingToolError } from './scenarios-native.mjs' import { processIdentity, sameProcess, processDescendsFrom } from '../../skill/scripts/parent-platform.mjs' import { scenarioPlan } from './scenarios.mjs' import { digest } from '../../skill/scripts/parent-delivery.mjs' @@ -178,3 +178,23 @@ test('束縛armが実event待ちで失敗しても私物holdを解除してfacto assert.equal(existsSync(release), false); await native.finalize(scope) assert.equal(readFileSync(release, 'utf8'), '{}'); assert.equal(closed, true) }) + + +test('束縛失敗はClaude実tool名と同ID user.tool_resultの原包装から読む', t => { + const dir = mkdtempSync(join(tmpdir(), 'peertable-binding-result-')); t.after(() => rmSync(dir, { recursive: true, force: true })) + const file = join(dir, 'transcript.jsonl'), error = { schema: 'peertable.parent-error.v1', state: 'failed', error_code: 'PARENT_BIND_TIMEOUT' } + const tool = { type: 'tool_use', id: 'toolu_actual', name: 'mcp__peertable_parent__parent_join', input: { project: '/own', name: '親' } }, result = { type: 'tool_result', tool_use_id: tool.id, content: [{ type: 'text', text: JSON.stringify({ content: [{ type: 'text', text: JSON.stringify(error) }], isError: true }) }], is_error: true } + const records = [{ type: 'assistant', sessionId: 'own', message: { content: [tool] } }, { type: 'user', sessionId: 'other', message: { content: [result] } }, { type: 'user', sessionId: 'own', message: { content: [{ ...result, tool_use_id: '他tool' }] } }, { type: 'user', sessionId: 'own', uuid: 'result-entry', message: { content: [result] } }] + const write = rows => writeFileSync(file, rows.map(row => JSON.stringify(row)).join('\n') + '\n' + '{"未確定tail":') + const options = { harness: 'claude', file, session: 'own', useId: tool.id, expected: error.error_code } + write(records); const proof = bindingToolError(options) + assert.deepEqual(proof.output, result); assert.deepEqual(proof.error, error); assert.equal(proof.use.name, tool.name); assert.equal(proof.output_order, 3); assert.equal(proof.output_entry_uuid, 'result-entry') + write(records.slice(0, 3)); assert.equal(bindingToolError(options), null) + write([{ ...records[0], message: { content: [{ ...tool, name: 'mcp__other__parent_join' }] } }, records[3]]); assert.equal(bindingToolError(options), null) + for (const harness of ['cursor', 'grok']) { + const output = harness === 'grok' ? { type: 'MCP', tool_name: 'parent_join', output: { OkayOutput: JSON.stringify(error) } } : { content: [{ type: 'text', text: JSON.stringify(error) }] } + const use = { id: tool.id, name: 'parent_join', session: 'own', output, order: 4 } + assert.deepEqual(bindingToolError({ ...options, harness, seen: { toolUses: [use] } }).error, error) + assert.equal(bindingToolError({ ...options, harness, seen: { toolUses: [{ ...use, session: 'other' }] } }), null) + } +}) From db414af7154fae32d07c1ef5cc0d6aa334477c3d Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:00:41 +0900 Subject: [PATCH 42/72] =?UTF-8?q?=E5=8F=97=E5=85=A5controller=E3=81=AE?= =?UTF-8?q?=E5=AE=9Frun=E6=AC=A0=E9=99=A53=E4=BB=B6=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E7=90=86=E3=81=99=E3=82=8B(work-process=E6=8B=A1=E5=BC=B5?= =?UTF-8?q?=E5=AD=90=E3=83=BBidle=E9=96=8B=E5=A7=8B=E3=83=BBburst=E8=A6=B3?= =?UTF-8?q?=E6=B8=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 用途名が拡張子を持つ試験artifactは正規path関数でそのまま渡し、work-process.mjsを 実Node scriptとして書く(従来は.mjs.jsonになりnodeがJSONとして解析して失敗)。 - idleはjoin後の親最終返答が会話に確定する観測を待ってから基準にする。 - burstは未読保持のspoolと同じoffsetまで返した実tool途中pageが揃うまで待つ。 Co-Authored-By: Claude Sonnet 5.5 --- .../scenarios-context.mjs | 37 +++++++++++++----- .../scenarios.test.mjs | 39 +++++++++++++++++++ 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/experiments/parent-product-acceptance/scenarios-context.mjs b/experiments/parent-product-acceptance/scenarios-context.mjs index 343b23a6..3ec0e2d0 100644 --- a/experiments/parent-product-acceptance/scenarios-context.mjs +++ b/experiments/parent-product-acceptance/scenarios-context.mjs @@ -53,6 +53,15 @@ export async function createScenarioProxy({ backend, artifact }) { } // parent_readの公式tool出力をraw JSONLから取得する。本文はJSON decodeだけで、任意の置換をしない。 +// 試験専用artifactの正規path。用途名が拡張子(.json/.jsonl/.mjs)を持てばそのまま使い、無ければ.jsonを付ける。 +export const artifactPath = (dir, label) => join(dir, /\.(?:json|jsonl|mjs)$/u.test(label) ? label : `${label}.json`) +// join後の親最終返答が会話に確定した(応答があり、未確定の書込み末尾が無い)ことの観測。 +export const finalReplyConfirmed = seen => seen.replies.length > 0 && seen.pending_tail === 0 +// spoolの未読保持state(途中offset・receipt無し)と、同じoffsetまで返した実tool途中pageが同時に揃った時だけ返す。 +export const intermediatePageMatch = (record, pages) => { + if (record?.state !== 'sending' || !(record.claim.offset > 0 && record.claim.offset < record.event.body.length) || record.receipt !== null) return null + return pages.find(item => item.page.complete === false && item.page.offset + item.page.event.body.length === record.claim.offset) ?? null +} export function nativeReadPages(harness, file, deliveryId) { const rows = readFileSync(file, 'utf8').split('\n'); rows.pop() const pages = []; let turn = null @@ -176,7 +185,7 @@ export async function createScenarioContext(options) { if (waiter?.native_task?.id && sameProcess(waiter.owner) && !nativeRegistrations.some(item => item.native_task.id === waiter.native_task.id && item.parent_session === target.meta.parent_session)) nativeRegistrations.push({ parent_session: target.meta.parent_session, endpoint_id: target.spool.id, waiter_owner: waiter.owner, native_task: waiter.native_task, owner_verified: true, observed_at: new Date().toISOString() }) } } - const artifactFor = (scope, label) => { const dir = join(privateDir, 'scenarios', scope.runId, scope.scenario); mkdirSync(dir, { recursive: true, mode: 0o700 }); return join(dir, `${label}.json`) } + const artifactFor = (scope, label) => { const dir = join(privateDir, 'scenarios', scope.runId, scope.scenario); mkdirSync(dir, { recursive: true, mode: 0o700 }); return artifactPath(dir, label) } const result = (scope, expectation, source, detail = {}) => { captureNativeRegistrations() const artifact = artifactFor(scope, `${scope.step_index ?? randomUUID()}-${expectation}`) @@ -288,11 +297,14 @@ export async function createScenarioContext(options) { } } actions.idle_wait = async (_, scope) => { - const seen = observe(), count = seen.replies.length - if (!count || !screen) fail('ACCEPTANCE_IDLE_ADAPTER_MISSING', '実TUIと最終応答の待機観測が必要です') - const last = seen.replies.at(-1) + if (!screen) fail('ACCEPTANCE_IDLE_ADAPTER_MISSING', '実TUIの観測が必要です') + // joinは製品状態のverifiedが先に立つ。親の最終返答が会話へ確定するまで待ってから基準にする。 + const seen = await until('join後の実最終返答の会話への確定', () => { const value = observe(); return finalReplyConfirmed(value) ? value : null }, 120000, 100) + let count = seen.replies.length, last = seen.replies.at(-1) await until('実親idle', async () => { const view = await screen(), current = observe() + // 待つ間に親が別の最終返答を確定したら、その返答を基準にidleを待ち直す。 + if (finalReplyConfirmed(current) && current.replies.at(-1).order !== last.order) { count = current.replies.length; last = current.replies.at(-1); return null } if (meta.harness === 'cursor') { if (!nativeStopFile) fail('ACCEPTANCE_IDLE_NATIVE_END_MISSING', 'Cursorの専用stop hook保存先がありません') const events = existsSync(nativeStopFile) ? readJsonl(readFileSync(nativeStopFile, 'utf8'), nativeStopFile).rows.map(item => item.row) : [] @@ -369,12 +381,17 @@ export async function createScenarioContext(options) { } actions.burst_observe = async (_, scope) => { const posted = faultState.get(`${scope.runId}:burst`) - const sample = await until('実page未読保持の観測', () => { - const records = posted.map(item => recordFor(item)).filter(Boolean) - return records.find(item => item.state === 'sending' && item.claim.offset > 0 && item.claim.offset < item.event.body.length && item.receipt === null) - }, 120000, 50) - const pages = ['cursor', 'grok'].includes(meta.harness) ? (observe().pages ?? []).filter(item => item.page.delivery_id === sample.delivery_id) : nativeReadPages(meta.harness, file, sample.delivery_id) - if (!pages.length || pages.at(-1).page.complete) fail('ACCEPTANCE_BURST_INTERMEDIATE_PAGE_MISSING', '未読状態に相関する実tool途中pageがありません') + const pagesOf = deliveryId => ['cursor', 'grok'].includes(meta.harness) ? (observe().pages ?? []).filter(item => item.page.delivery_id === deliveryId) : nativeReadPages(meta.harness, file, deliveryId) + // 未読保持のspoolと実中間pageは同時に揃うまで待つ。spool更新の方がtranscript記録より先に起きる。 + const observed = await until('未読保持のspoolと実tool途中pageの同時観測', () => { + for (const item of posted) { + const sample = recordFor(item), pages = sample ? pagesOf(sample.delivery_id) : [] + const matched = intermediatePageMatch(sample, pages), again = sample && recordFor(item) + if (matched && again.state === 'sending' && again.claim.offset === sample.claim.offset && again.receipt === null) return { sample, pages } + } + return null + }, 120000, 50).catch(error => { if (error.code === 'ACCEPTANCE_TIMEOUT') fail('ACCEPTANCE_BURST_INTERMEDIATE_PAGE_MISSING', '未読状態に相関する実tool途中pageがありません'); throw error }) + const { sample, pages } = observed return result(scope, 'unread_until_last_page', 'harness_transcript', { delivery_id: sample.delivery_id, offset: sample.claim.offset, receipt: sample.receipt, native_page_count: pages.length }) } actions.burst_finish = async (_, scope) => { diff --git a/experiments/parent-product-acceptance/scenarios.test.mjs b/experiments/parent-product-acceptance/scenarios.test.mjs index f68be3ea..c3ca4264 100644 --- a/experiments/parent-product-acceptance/scenarios.test.mjs +++ b/experiments/parent-product-acceptance/scenarios.test.mjs @@ -163,3 +163,42 @@ test('Cursorのidleは対象会話/generationの公式completed stopだけを採 assert.equal(cursorIdleCompletion([{ event: {} }], last, 'own'), null) assert.throws(() => cursorIdleCompletion([entry], { ...last, session: '別会話' }, 'own'), { code: 'ACCEPTANCE_IDLE_NATIVE_END_MISSING' }) }) + +// 実runで確認した3欠陥(work-process拡張子・idle開始レース・burst観測レース)の最小再現。 +test('用途名に拡張子を持つartifactはそのまま渡り、実Node scriptとして実行できる', async () => { + const { artifactPath } = await import('./scenarios-context.mjs') + const { mkdtempSync, writeFileSync, readFileSync } = await import('node:fs') + const { spawnSync } = await import('node:child_process') + const { tmpdir } = await import('node:os') + const dir = mkdtempSync(join(tmpdir(), 'artifact-')) + const script = artifactPath(dir, 'work-process.mjs') + assert.equal(script, join(dir, 'work-process.mjs')) + assert.equal(artifactPath(dir, 'work-start.json'), join(dir, 'work-start.json')) + assert.equal(artifactPath(dir, '3-own_finite_lease'), join(dir, '3-own_finite_lease.json')) + writeFileSync(script, "import {writeFileSync} from 'node:fs';writeFileSync(process.argv[2],'ok')\n") + const out = join(dir, 'out.txt') + assert.equal(spawnSync(process.execPath, [script, out]).status, 0) + assert.equal(readFileSync(out, 'utf8'), 'ok') +}) + +test('idleはjoin後の親最終返答が会話に確定するまで基準にしない', async () => { + const { finalReplyConfirmed } = await import('./scenarios-context.mjs') + assert.equal(finalReplyConfirmed({ replies: [], pending_tail: 0 }), false) + assert.equal(finalReplyConfirmed({ replies: [{ order: 1 }], pending_tail: 1 }), false) + assert.equal(finalReplyConfirmed({ replies: [{ order: 1 }], pending_tail: 0 }), true) +}) + +test('burstは未読保持のspoolと同じoffsetまで返した実中間pageが揃うまで成立させない', async () => { + const { intermediatePageMatch } = await import('./scenarios-context.mjs') + const record = { state: 'sending', receipt: null, claim: { offset: 12000 }, event: { body: 'x'.repeat(24000) } } + const page = (offset, length, complete) => ({ page: { offset, complete, event: { body: 'x'.repeat(length) } } }) + // spoolが先に更新され、transcriptにまだ中間pageが無い(実runで観測した順序)。 + assert.equal(intermediatePageMatch(record, []), null) + assert.equal(intermediatePageMatch(record, [page(0, 6000, false)]), null) + assert.equal(intermediatePageMatch(record, [page(0, 12000, false)]).page.offset, 0) + // 最終pageや別offset、receipt済み・非sendingは未読保持ではない。 + assert.equal(intermediatePageMatch(record, [page(12000, 12000, true)]), null) + assert.equal(intermediatePageMatch({ ...record, receipt: { result: 'delivered' } }, [page(0, 12000, false)]), null) + assert.equal(intermediatePageMatch({ ...record, state: 'submitted' }, [page(0, 12000, false)]), null) + assert.equal(intermediatePageMatch({ ...record, claim: { offset: 0 } }, [page(0, 12000, false)]), null) +}) From 80975c0d286fe62decff3cc2b5ca72c8839ef335 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:15:11 +0900 Subject: [PATCH 43/72] =?UTF-8?q?lease=E5=85=A5=E5=8F=A3=E3=81=AE=E5=90=8D?= =?UTF-8?q?=E5=89=8D=E8=A1=9D=E7=AA=81=E3=81=A8=E5=85=AC=E5=BC=8F=E5=85=A8?= =?UTF-8?q?=E6=96=87reader=E5=88=9D=E6=9C=9F=E6=8C=87=E7=A4=BA=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scenarios-fixtures.mjs | 35 ++++++++++++++++--- .../scenarios-fixtures.test.mjs | 19 +++++++++- .../scenarios-native.mjs | 12 +++---- .../scenarios-native.test.mjs | 18 ++++++++++ 4 files changed, 73 insertions(+), 11 deletions(-) diff --git a/experiments/parent-product-acceptance/scenarios-fixtures.mjs b/experiments/parent-product-acceptance/scenarios-fixtures.mjs index 6554fc34..11d1e85a 100644 --- a/experiments/parent-product-acceptance/scenarios-fixtures.mjs +++ b/experiments/parent-product-acceptance/scenarios-fixtures.mjs @@ -29,6 +29,29 @@ export function appendGrokProductObserver(value, event, observer) { return value } +// 専用診断artifactへOS原応答を残し、process APIの失敗自体は伝播する。 +export function observeFixtureProcessIdentity(platform, pid, directory) { + try { return platform.processIdentity(pid) } + catch (error) { + const sample = (executable, argv) => { try { return { executable, argv, code: 0, stdout: execFileSync(executable, argv, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) } } catch (probe) { return { executable, argv, code: probe.status ?? null, stdout: probe.stdout?.toString() ?? '', stderr: probe.stderr?.toString() ?? '', error_code: probe.code } } } + const artifact = join(directory, `process-identity-failed-${pid}-${Date.now()}.json`) + const responses = process.platform === 'darwin' ? [sample('/bin/ps', ['-ww', '-p', String(pid), '-o', 'ppid=', '-o', 'lstart=', '-o', 'command=']), sample('/usr/sbin/lsof', ['-a', '-p', String(pid), '-d', 'txt', '-Fn'])] : [] + writeJson(artifact, { at: new Date().toISOString(), pid, error: { code: error.code, message: error.message, stack: error.stack }, responses }) + error.detail = { ...error.detail, pid, artifact }; throw error + } +} + +// 背景taskの実登録入力と、完了後の公式reader入力は別々に固定する。 +export function fixtureJoinInstruction({ harness, project, name }) { + const common = `Peertable parent_joinをproject=${project} name=${name}で1回呼んでください。同じ会話のroom配送の確認符号を原文のまま報告し、受信を継続してください。` + if (!['cursor', 'grok'].includes(harness)) return common + const registration = '製品receipt wait_process.native_tool.inputの完成済み入力を、指定されたnative背景toolへそのまま渡してください。taskの完了前に出力readerやparent_readで先取りしないでください。' + const read = harness === 'grok' + ? '公式task_completedで示された同じtask_idだけをget_command_or_subagent_outputのtask_ids配列に入れて全量を読みます。入力はtask_idsの1キーだけです。timeout_msキーは付けず、0も不可です。truncated:falseとcompletedを確認した公式全文readerの結果にdelivery_idがある場合だけ、そのIDをparent_readへ渡してください。' + : '同じShell taskの背景完了後、公式Shellが示す同taskの出力fileを公式Readで全文読みます。offset/limitを付けず、他taskのfileを読みません。その全文出力のdelivery_idをparent_readへ渡してください。' + return common + registration + read + 'continuation_tokenがある間は同じ配送のparent_readを完了まで続けます。その結果の次wait_processも同じ完成入力で登録してください。' +} + export function hookConfigurationSnapshot(hooks) { const beforeBytes = existsSync(hooks) ? readFileSync(hooks) : null const beforeHooks = beforeBytes ? JSON.parse(beforeBytes.toString('utf8').replace(/^\uFEFF/u, '')) : null @@ -83,10 +106,11 @@ export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUr if (adapter?.bind) adapter = adapter.bind(fixture) fixture.adapter = adapter if (!adapter?.resolveCli || !adapter.startup || !adapter.transcript || !adapter.read) fail('ACCEPTANCE_OFFICIAL_SURFACE_BOUNDARY_UNCONFIRMED', `${harness}: 公式task/transcriptの実測adapterが必要です`) + fixture.identityArtifact = join(directory, 'native-process-identity.json') fixture.trackOwnProcesses = async () => { if (!fixture.pty) return const pane = await fixture.aiterm.observe(fixture.pty) - if (pane.pane_process?.pid && !fixture.paneOwner) fixture.paneOwner = platform.processIdentity(pane.pane_process.pid) + if (pane.pane_process?.pid && !fixture.paneOwner) fixture.paneOwner = observeFixtureProcessIdentity(platform, pane.pane_process.pid, directory) for (const endpoint of projectEndpoints(project)) { const state = endpoint.read() if (state.caller?.owner && platform.sameProcess(state.caller.owner)) { fixture.owner = state.caller.owner; if (!fixture.nativeOwners.some(owner => owner.pid === state.caller.owner.pid && owner.started === state.caller.owner.started)) fixture.nativeOwners.push(state.caller.owner) } @@ -102,9 +126,10 @@ export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUr const descendants = new Set([fixture.paneOwner.pid]); let added = true while (added) { added = false; for (const row of rows) if (descendants.has(row.parent) && !descendants.has(row.pid)) { descendants.add(row.pid); added = true } } for (const pid of descendants) { - const owner = platform.processIdentity(pid) + const owner = observeFixtureProcessIdentity(platform, pid, directory) if (owner && platform.processHarness(owner) === harness && !fixture.nativeOwners.some(item => item.pid === owner.pid && item.started === owner.started)) { fixture.nativeOwners.push(owner); fixture.owner ??= owner } } + writeJson(fixture.identityArtifact, { at: new Date().toISOString(), project, harness, pty: fixture.pty, pane: fixture.paneOwner, native: fixture.nativeOwners, receiver: fixture.receiverOwners }) } fixture.rpc = async (method, params) => { if (harness !== 'codex') fail('ACCEPTANCE_FIXTURE_RPC_HARNESS', harness) @@ -242,6 +267,7 @@ export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUr writeFileSync(launcher, `import {readFileSync} from 'node:fs';import {spawn} from 'node:child_process';const p=JSON.parse(readFileSync(process.argv[2],'utf8'));const child=spawn(p.invocation.executable,p.invocation.argv,{cwd:p.project,stdio:'inherit',env:{...process.env,PEERTABLE_TOKEN_SOURCE_FILE:p.tokenFile}});child.on('exit',(code)=>process.exit(code??1));\n`, { mode: 0o600 }) if (!fixture.aiterm) fixture.aiterm = await openAiterm() if (!fixture.pty) fixture.pty = await fixture.aiterm.open(`pt-fixture-${randomUUID().slice(0, 8)}`, process.platform === 'win32' ? 'pwsh' : undefined) + writeJson(fixture.identityArtifact, { at: new Date().toISOString(), project, harness, pty: fixture.pty, pane: fixture.paneOwner, native: fixture.nativeOwners, receiver: fixture.receiverOwners }) await fixture.trackOwnProcesses() await fixture.aiterm.send(fixture.pty, platform.shellCommand(process.execPath, [launcher, input])) await until('通常HOMEの公式CLI起動', async () => { await fixture.trackOwnProcesses(); const action = adapter.startup(await fixture.aiterm.screen(fixture.pty)); if (action?.blocked) fail('ACCEPTANCE_FIXTURE_HARNESS_BLOCKED', action.blocked); if (action?.keys) { for (const key of action.keys) { await fixture.aiterm.key(fixture.pty, key); await sleep(500) } return false } return action?.ready }, 120000, 1500) @@ -261,7 +287,7 @@ export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUr fixture.target = { meta, spool, api: apiFor(room), observe: boundObserve, submit: fixture.submit, file, nativeStopFile: fixture.observer?.observations, screen: () => fixture.aiterm.screen(fixture.pty), fixture } return fixture.target } - fixture.join = async (previousEndpoint = null) => { await fixture.submit(`Peertable parent_joinをproject=${project} name=${name}で1回呼んでください。同じ会話のroom配送の確認符号を原文のまま報告し、受信を継続してください。Cursor/Grokの背景登録とparent_readは製品receiptの完成済み入力を公式toolへ渡してください。`); return fixture.refreshTarget(previousEndpoint) } + fixture.join = async (previousEndpoint = null) => { await fixture.submit(fixtureJoinInstruction({ harness, project, name })); return fixture.refreshTarget(previousEndpoint) } fixture.restore = () => { for (const [file, original] of fixture.backups) { if (original === null) rmSync(file, { force: true }); else { writeFileSync(file, original, { mode: 0o600 }); if (!readFileSync(file).equals(original)) fail('ACCEPTANCE_FIXTURE_RESTORE_FAILED', file) } }; fixture.backups.clear(); configuration.assertHooksUnchanged() } fixture.stop = async () => { await fixture.trackOwnProcesses() @@ -283,7 +309,8 @@ export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUr try { await fixture.stop() } catch (error) { errors.push(error) } // 起動dialog失敗・joinなしでも、own paneを公式APIで閉じ、既知のnative親を終了させる。 if (fixture.pty) { - try { await fixture.trackOwnProcesses(); await fixture.aiterm.close(fixture.pty); fixture.pty = null } catch (error) { errors.push(error) } + try { await fixture.trackOwnProcesses() } catch (error) { errors.push(error) } + try { await fixture.aiterm.close(fixture.pty); fixture.pty = null } catch (error) { errors.push(error) } } try { await waitOwnedFixtureExit({ readEndpoints: () => projectEndpoints(project), sameProcess: platform.sameProcess, knownOwners: [...fixture.nativeOwners, ...fixture.receiverOwners, fixture.paneOwner].filter(Boolean), indexExists: id => existsSync(join(homedir(), '.peertable/parent-receivers/endpoints', `${id}.json`)), timeout: 30000 }) diff --git a/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs b/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs index 740ed1cf..62c56963 100644 --- a/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs +++ b/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs @@ -7,7 +7,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { processIdentity, sameProcess } from '../../skill/scripts/parent-platform.mjs' -import { assertOnlyProjectTrustChanged, hookConfigurationSnapshot, waitOwnedFixtureExit, nativeHookObserverSource, appendGrokProductObserver } from './scenarios-fixtures.mjs' +import { assertOnlyProjectTrustChanged, hookConfigurationSnapshot, waitOwnedFixtureExit, nativeHookObserverSource, appendGrokProductObserver, observeFixtureProcessIdentity, fixtureJoinInstruction } from './scenarios-fixtures.mjs' test('fixture終了は実child消失の後も製品自己停止と索引撤去を待つ', async t => { const child = spawn(process.execPath, ['-e', 'setTimeout(()=>process.exit(0),150)'], { stdio: 'ignore' }) @@ -102,3 +102,20 @@ test('束縛observerは同eventのparent_joinだけを実processでholdする', assert.deepEqual(entries.map(row => row.held), [false, false, true]); assert.equal(call.child.exitCode, null); assert.equal(entries[2].control_release, release); assert.equal(sameProcess(entries[2].owner), true) writeFileSync(release, '{}'); assert.equal(await call.ended, 0); assert.equal(sameProcess(entries[2].owner), false) }) + + +test('process API失敗は実OS原応答を私物artifactへ保存し、元errorとstackを伝播する', t => { + const directory = mkdtempSync(join(tmpdir(), 'peertable-process-diagnostic-')); t.after(() => rmSync(directory, { recursive: true, force: true })) + const error = Object.assign(new Error('実schema境界の再現'), { code: 'PARENT_PROCESS_API_SCHEMA_INVALID' }) + assert.throws(() => observeFixtureProcessIdentity({ processIdentity: () => { throw error } }, process.pid, directory), caught => caught === error) + const raw = JSON.parse(readFileSync(error.detail.artifact, 'utf8')); assert.equal(raw.pid, process.pid); assert.equal(raw.error.code, error.code); assert.equal(raw.error.stack, error.stack) + if (process.platform === 'darwin') { assert.equal(raw.responses[0].code, 0); assert.match(raw.responses[0].stdout, /node/u); assert.equal(raw.responses[1].code, 0); assert.match(raw.responses[1].stdout, /n\//u) } + assert.deepEqual(observeFixtureProcessIdentity({ processIdentity }, process.pid, directory), processIdentity(process.pid)) +}) + +test('Grok初期joinは実task完了後のtask_idsだけの全文readerを指示する', () => { + const grok = fixtureJoinInstruction({ harness: 'grok', project: '/私物 project', name: '親' }) + assert.match(grok, /公式task_completed.*同じtask_id/u); assert.match(grok, /入力はtask_idsの1キーだけ/u); assert.match(grok, /timeout_msキーは付けず、0も不可/u); assert.match(grok, /公式全文readerの結果にdelivery_idがある場合だけ/u) + const cursor = fixtureJoinInstruction({ harness: 'cursor', project: '/私物', name: '親' }); assert.match(cursor, /公式Readで全文/u); assert.match(cursor, /offset\/limitを付けず/u); assert.doesNotMatch(cursor, /get_command_or_subagent_output/u) + assert.doesNotMatch(fixtureJoinInstruction({ harness: 'claude', project: '/私物', name: '親' }), /task_ids|Shell/u) +}) diff --git a/experiments/parent-product-acceptance/scenarios-native.mjs b/experiments/parent-product-acceptance/scenarios-native.mjs index cfa8fd0b..91dcd9e4 100644 --- a/experiments/parent-product-acceptance/scenarios-native.mjs +++ b/experiments/parent-product-acceptance/scenarios-native.mjs @@ -203,7 +203,7 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { const state = s => saved.get(s.runId) ?? (() => { const value = {}; saved.set(s.runId, value); return value })() const observerRows = fixture => rows(fixture.observer?.observations) const hookObservation = (s, expectation, detail) => s.result(s, expectation, 'official_hook', detail) - const join = async (s, alias = 'self') => { const target = s.context.endpoint(alias), fixture = fixtureFor(target); await fixture.join(); return fixture.target } + const rejoinEndpoint = async (s, alias = 'self') => { const target = s.context.endpoint(alias), fixture = fixtureFor(target); await fixture.join(); return fixture.target } const recordPending = s => s.pending.filter(item => item.scope.runId === s.runId && item.scope.scenario === s.scenario) const confirmOne = s => { const last = s.checks.at(-1); if (!last || last.count !== 1 || last.status !== 'passed') fail('ACCEPTANCE_NATIVE_DELIVERY_MISSING', s.scenario); return last } const actions = {} @@ -412,7 +412,7 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { const old = recovery.owner await pauseOwnedReceiver(old, { target: recovery.target, fixture: recovery.fixture, sameProcess: s.sameProcess, processDescendsFrom: s.processDescendsFrom, paused, resume: true }) process.kill(old.pid, 'SIGKILL'); await s.until('停止した受信process消失', () => !s.sameProcess(old), 10000, 50) - const target = await join(s) + const target = await rejoinEndpoint(s) if (target.spool.id !== recovery.target.spool.id || s.recordFor(post).event.body !== post.body) fail('ACCEPTANCE_READY_RESTART_BINDING', 'readyとendpointを継承しませんでした') return s.result(s, 'ready_inherited', 'os_process', { suspension: recovery.monitor?.suspended ?? recovery.operation, killed_owner: old, new_watcher: target.spool.read().watcher, new_waiter: target.spool.read().waiter, delivery_id: before.delivery_id, body_sha256: sha256(post.body), official_transcript: target.file }) } @@ -469,7 +469,7 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { const after = await s.until('公式native task終了出力', async () => { const value = await controller.read(task.id, { session: target.meta.parent_session }); return value.finished === true ? value : null }, outcome === 'timeout' ? controller.timeoutWindowMs : 30000) if (after.id !== task.id || after.session !== target.meta.parent_session || after.outcome !== outcome || after.delivered === true) fail('ACCEPTANCE_BACKGROUND_END_ROUNDED', 'cancel/timeout/exitを本文配送へ丸めました') state(s).background ??= []; state(s).background.push({ task, before, response, after }) - if (outcome !== 'exit') await join(s) + if (outcome !== 'exit') await rejoinEndpoint(s) return s.result(s, `native_task_${outcome}_observed`, 'native_task', { native_task: task, request_response: response, before, after, task_artifact: after.artifact }) } } @@ -479,7 +479,7 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { return s.result(s, 'task_end_not_delivered', 'native_task', { native_ends: observations }) } actions.receiver_rearm = async (_, s) => { - const before = self(s).spool.read(), target = await join(s), after = target.spool.read() + const before = self(s).spool.read(), target = await rejoinEndpoint(s), after = target.spool.read() if (target.spool.id !== self(s).spool.id || after.cursor < before.cursor || after.state !== 'verified' || after.runtime !== 'armed') fail('ACCEPTANCE_REARM_IDENTITY_OR_CURSOR', '同endpoint/cursorで再武装していません') if (['cursor', 'grok'].includes(s.harness) && (!after.waiter?.native_task || !s.sameProcess(after.waiter.owner))) fail('ACCEPTANCE_REARM_NATIVE_TASK_MISSING', '完成済みnative taskの再登録がありません') return s.result(s, s.scenario === 'lease' ? 'same_endpoint_cursor_rearmed' : 'native_receiving_rearmed', 'harness_transcript', { endpoint_id: target.spool.id, before_cursor: before.cursor, after_cursor: after.cursor, native_waiter: after.waiter }) @@ -617,7 +617,7 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { actions.deadline_restore = async (_, s) => { const pending = state(s).deadline if (pending) for (const release of pending.releases) writeFileSync(release, '{}') - const target = await join(s) + const target = await rejoinEndpoint(s) return s.result(s, 'new_join_verified', 'harness_transcript', { parent_session: target.meta.parent_session, endpoint_id: target.spool.id, state: target.spool.read().state }) } @@ -637,7 +637,7 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { const after = target.spool.read() if (!s.sameProcess(target.meta.parent_process) || after.cursor !== before.cursor || !isDeepStrictEqual(after.records, before.records)) fail('ACCEPTANCE_LIFECYCLE_HISTORY_LOST', '更新が親processまたは配送履歴を変更しました') await fixtureFor(target).adapter.reconnectMcp(fixtureFor(target)) - await join(s) + await rejoinEndpoint(s) return s.result(s, mode === 'same' ? 'same_version_single_registration' : 'new_version_history_binding_preserved', 'installed_package', { before_version: s.meta.package_version, after_version: installed.version, tarball_sha256: sha256(readFileSync(tarball)), package_output: output, endpoint_id: target.spool.id, cursor: after.cursor }) } actions.same_version_update = (_, s) => lifecycleOperation(s, 'same') diff --git a/experiments/parent-product-acceptance/scenarios-native.test.mjs b/experiments/parent-product-acceptance/scenarios-native.test.mjs index e2857566..4d81f13d 100644 --- a/experiments/parent-product-acceptance/scenarios-native.test.mjs +++ b/experiments/parent-product-acceptance/scenarios-native.test.mjs @@ -198,3 +198,21 @@ test('束縛失敗はClaude実tool名と同ID user.tool_resultの原包装から assert.equal(bindingToolError({ ...options, harness, seen: { toolUses: [{ ...use, session: 'other' }] } }), null) } }) + +// slot登録部分のfixture値で入口の名前衝突を再現する。24hや実CLI配送の代用ではない。 +test('lease armのmodule path結合はendpoint再joinを呼ばず4harnessで文字列を返す', async t => { + const dir = mkdtempSync(join(tmpdir(), 'peertable-lease-path-')); t.after(() => rmSync(dir, { recursive: true, force: true })) + const owner = processIdentity(process.pid) + for (const harness of ['claude', 'codex', 'cursor', 'grok']) { + let rejoined = 0; const fixture = { harness, adapter: {}, join: async () => { rejoined++ } } + const nativeName = harness === 'cursor' ? 'Shell' : 'run_terminal_command', input = { command: '専用登録入力' } + const state = { state: 'verified', runtime: 'armed', endpoint_id: 'own', watcher: owner, caller: { owner }, waiter: { owner, native_task: { id: 'task', pid: owner.pid, process_identity: owner, input: { name: nativeName, input } } } } + const target = { fixture, meta: { harness, parent_session: 'own' }, spool: { id: 'own', read: () => state }, observe: () => ({ toolUses: [{ name: nativeName, session: 'own', input, id: 'tool', turn_id: 'turn' }], tasks: [{ tool_use_id: 'tool', id: 'task', pid: owner.pid }] }) } + const native = createNativeActions({ factory: { close: async () => {} }, primary: fixture }) + const scope = { harness, runId: harness, pkg: process.cwd(), checks: [], context: { endpoint: () => target }, artifactFor: () => join(dir, harness + '.json'), processIdentity, sameProcess, processDescendsFrom, until: async (_, probe) => { const value = await probe(); assert.ok(value); return value }, result: (_, expectation, kind, detail) => ({ expectation, kind, ...detail }) } + const result = await native.actions.lease_expiry_arm({}, scope) + assert.equal(result.expectation, 'own_finite_lease'); assert.equal(rejoined, 0) + assert.equal(result.product_connection_source ?? result.lease_source, join(process.cwd(), 'skill/scripts/parent-receivers', harness === 'codex' ? 'codex.mjs' : harness === 'claude' ? 'claude.mjs' : 'background.mjs')) + await native.finalize(scope) + } +}) From 13a506f976f472e80e5207357feec530e752f03d Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:15:54 +0900 Subject: [PATCH 44/72] =?UTF-8?q?Codex=20idle=E3=81=AF=E8=BF=94=E7=AD=94?= =?UTF-8?q?=E7=9B=B4=E5=BE=8C=E3=81=AEtask=E6=9C=AA=E5=AE=8C=E4=BA=86?= =?UTF-8?q?=E3=82=92=E5=BE=85=E3=81=A1=E3=80=81=E5=85=AC=E5=BC=8Ftask=5Fco?= =?UTF-8?q?mplete=E8=A8=98=E9=8C=B2=E3=81=A7=E5=88=A4=E5=AE=9A=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 親の返答が表示された直後はtaskが終わっておらず、従来は終了記録が無いと即座に ACCEPTANCE_IDLE_NATIVE_END_MISSINGで失敗していた(実run: Mac codex idle/consecutive)。 返答より後の公式task_completeが記録されるまで待ち、期限内に来なければ同じtyped errorにする。 Co-Authored-By: Claude Sonnet 5.5 --- .../parent-product-acceptance/scenarios-context.mjs | 6 ++++-- .../parent-product-acceptance/scenarios.test.mjs | 11 +++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/experiments/parent-product-acceptance/scenarios-context.mjs b/experiments/parent-product-acceptance/scenarios-context.mjs index 3ec0e2d0..8e3da9b2 100644 --- a/experiments/parent-product-acceptance/scenarios-context.mjs +++ b/experiments/parent-product-acceptance/scenarios-context.mjs @@ -109,6 +109,8 @@ export function noToolsReception({ posted, record, receipt, seen, session, runti fail('ACCEPTANCE_NO_TOOLS_STATE_UNEXPLAINED', `実返答/receipt/原文保持を説明できません: ${runtime}/${record.state}/${receipt?.state ?? 'missing'}`) } +// 対象last turnの公式task_completeが返答より後に記録済みであること。 +export const codexTurnCompleted = (turns, last) => { const ended = turns.get(last.turn_id); return Boolean(ended && ended.end_kind === 'task_complete' && ended.completed_order >= last.order) } export function codexTurns(file) { const turns = new Map(); let current = null for (const [order, line] of readFileSync(file, 'utf8').split('\n').slice(0, -1).entries()) { @@ -323,8 +325,8 @@ export async function createScenarioContext(options) { return prompt.test(view) && current.replies.at(-1)?.order === last.order }, 120000, 1000) // promptが作業中にも表示される実装があるため、native task終了の記録も必要にする。 - const ended = meta.harness === 'codex' ? codexTurns(file).get(last.turn_id) : null - if (meta.harness === 'codex' && (!ended || ended.end_kind !== 'task_complete' || ended.completed_order < last.order)) fail('ACCEPTANCE_IDLE_NATIVE_END_MISSING', '対象last turnのtask_completeを確認できません') + // 返答の表示直後はまだtaskが終わっていない。公式task_completeが記録されるまで待ち、期限内に来なければtyped errorにする。 + if (meta.harness === 'codex') await until('対象last turnのtask_complete', () => codexTurnCompleted(codexTurns(file), last), 120000, 100).catch(error => { if (error.code === 'ACCEPTANCE_TIMEOUT') fail('ACCEPTANCE_IDLE_NATIVE_END_MISSING', '対象last turnのtask_completeを確認できません'); throw error }) faultState.set(`${scope.runId}:idle`, { turn: last.turn_id, order: last.order, rows: seen.rows }) return result(scope, 'idle_without_input', 'harness_transcript', { idle_turn: last.turn_id, reply_order: last.order, official_stop: faultState.get(`${scope.runId}:idle_stop`) ?? null }) } diff --git a/experiments/parent-product-acceptance/scenarios.test.mjs b/experiments/parent-product-acceptance/scenarios.test.mjs index c3ca4264..33912495 100644 --- a/experiments/parent-product-acceptance/scenarios.test.mjs +++ b/experiments/parent-product-acceptance/scenarios.test.mjs @@ -202,3 +202,14 @@ test('burstは未読保持のspoolと同じoffsetまで返した実中間pageが assert.equal(intermediatePageMatch({ ...record, state: 'submitted' }, [page(0, 12000, false)]), null) assert.equal(intermediatePageMatch({ ...record, claim: { offset: 0 } }, [page(0, 12000, false)]), null) }) + +test('Codex idleは返答表示直後のtask未完了を失敗にせず、task_complete記録まで待つ', async () => { + const { codexTurnCompleted } = await import('./scenarios-context.mjs') + const last = { turn_id: 'turn', order: 5 } + const turns = ended => new Map([['turn', { turn_id: 'turn', started_order: 1, completed_order: ended?.order ?? null, end_kind: ended?.kind }]]) + assert.equal(codexTurnCompleted(turns(null), last), false) // 返答は出たがtaskは実行中 + assert.equal(codexTurnCompleted(turns({ order: 9, kind: 'turn_aborted' }), last), false) // 中断は完了ではない + assert.equal(codexTurnCompleted(turns({ order: 3, kind: 'task_complete' }), last), false) // 返答より前の完了は流用しない + assert.equal(codexTurnCompleted(turns({ order: 9, kind: 'task_complete' }), last), true) + assert.equal(codexTurnCompleted(new Map(), last), false) +}) From 356f9c408bfb8a46327651130b513b21b1b2842a Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:21:20 +0900 Subject: [PATCH 45/72] =?UTF-8?q?OS=E3=81=AE=E7=9F=AD=E5=91=BDprocess?= =?UTF-8?q?=E7=B5=82=E4=BA=86=E3=81=A8Grok=E5=85=AC=E5=BC=8F=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E5=85=88=E3=81=AE=E6=9C=AC=E4=BA=BA=E8=AA=8D=E8=AD=98?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skill/scripts/parent-delivery.test.mjs | 3 ++ .../parent-platform-darwin-exit.test.mjs | 40 +++++++++++++++++++ skill/scripts/parent-platform.mjs | 7 +++- 3 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 skill/scripts/parent-platform-darwin-exit.test.mjs diff --git a/skill/scripts/parent-delivery.test.mjs b/skill/scripts/parent-delivery.test.mjs index 3850b12c..2edf332f 100644 --- a/skill/scripts/parent-delivery.test.mjs +++ b/skill/scripts/parent-delivery.test.mjs @@ -155,6 +155,9 @@ test('CursorのNode optionとGrok公式native名を認識し、preloadや評価 assert.equal(processHarness({ executable: node, command: `node --eval "${cursor}"` }), null) assert.equal(processHarness({ executable: '/home/kite/.grok/downloads/grok-linux-x86_64', command: 'grok --always-approve' }), 'grok') assert.equal(processHarness({ executable: '/home/kite/.grok/downloads/grok-macos-aarch64', command: 'grok' }), 'grok') + assert.equal(processHarness({ executable: '/home/kite/.grok/downloads/grok-1.0.41-linux-x86_64', command: 'grok --always-approve' }), 'grok') + assert.equal(processHarness({ executable: '/home/kite/.grok/downloads/grok-1.0.41-macos-aarch64', command: 'grok' }), 'grok') + assert.equal(processHarness({ executable: '/tmp/grok-1.0.41-unknown-x86_64', command: 'grok' }), null) assert.equal(processHarness({ executable: '/tmp/grok-unknown-name', command: 'grok' }), null) }) diff --git a/skill/scripts/parent-platform-darwin-exit.test.mjs b/skill/scripts/parent-platform-darwin-exit.test.mjs new file mode 100644 index 00000000..39415e10 --- /dev/null +++ b/skill/scripts/parent-platform-darwin-exit.test.mjs @@ -0,0 +1,40 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import childProcess from 'node:child_process' +import { syncBuiltinESMExports } from 'node:module' +import { processIdentity } from './parent-platform.mjs' + +test('macOSのps成功後lsof空出力は、本人の消失を確認した場合だけ終了になる', { skip: process.platform !== 'darwin' }, t => { + const pid = 2140000000 + let alive = false, calls = [] + t.mock.method(childProcess, 'execFileSync', (file, args) => { + calls.push({ file, args }) + if (file === '/usr/sbin/lsof') return '' + if (args.includes('command=')) return '1 Tue Sep 29 05:13:09 2026 /bin/zsh -c prompt-submit' + assert.deepEqual(args, ['-p', String(pid), '-o', 'pid=']) + if (alive) return `${pid}\n` + throw Object.assign(new Error('終了済み'), { status: 1, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }) + }) + syncBuiltinESMExports() + try { + assert.equal(processIdentity(pid), null) + assert.equal(calls.length, 3) + alive = true; calls = [] + assert.throws(() => processIdentity(pid), { code: 'PARENT_PROCESS_API_SCHEMA_INVALID' }) + assert.equal(calls.length, 3) + } finally { + t.mock.restoreAll() + syncBuiltinESMExports() + } +}) + +test('macOSの生存確認APIの別異常を終了として扱わない', { skip: process.platform !== 'darwin' }, t => { + t.mock.method(childProcess, 'execFileSync', (file, args) => { + if (file === '/usr/sbin/lsof') return '' + if (args.includes('command=')) return '1 Tue Sep 29 05:13:09 2026 /bin/zsh -c prompt-submit' + throw Object.assign(new Error('権限異常'), { status: 1, stdout: Buffer.alloc(0), stderr: Buffer.from('denied') }) + }) + syncBuiltinESMExports() + try { assert.throws(() => processIdentity(2140000000), { code: 'PARENT_PROCESS_API_FAILED' }) } + finally { t.mock.restoreAll(); syncBuiltinESMExports() } +}) diff --git a/skill/scripts/parent-platform.mjs b/skill/scripts/parent-platform.mjs index aad8723b..6948bac6 100644 --- a/skill/scripts/parent-platform.mjs +++ b/skill/scripts/parent-platform.mjs @@ -79,7 +79,9 @@ export function processIdentity(pid, { includeExecutable = true } = {}) { try { readFileSync(`/proc/${pid}/stat`) } catch (probe) { if (probe.code === 'ENOENT') return null; throw failure('PARENT_PROCESS_API_FAILED', probe.message) } } - if (process.platform === 'darwin' && error.status === 1) { + // ps成功後に短命processが終了すると、lsofは成功のまま空出力になる。 + // OSで本人の消失を確認した場合だけ終了とし、存命中の形式異常は保持する。 + if (process.platform === 'darwin' && (error.status === 1 || error.code === 'PARENT_PROCESS_API_SCHEMA_INVALID')) { try { execFileSync('/bin/ps', ['-p', String(pid), '-o', 'pid='], { stdio: ['ignore', 'pipe', 'pipe'] }) } catch (probe) { if (probe.status === 1 && !String(probe.stdout ?? '').trim() && !String(probe.stderr ?? '').trim()) return null; throw failure('PARENT_PROCESS_API_FAILED', probe.message) } } @@ -104,7 +106,8 @@ export function processHarness(identity) { const executable = (identity.executable_name ?? basename(identity.executable ?? '')).toLowerCase() const native = { 'claude': 'claude', 'claude.exe': 'claude', 'codex': 'codex', 'codex.exe': 'codex', 'grok': 'grok', 'grok.exe': 'grok', 'cursor-agent': 'cursor', 'cursor.exe': 'cursor', 'cursor': 'cursor' } if (native[executable]) return native[executable] - if (/^grok-(?:macos|linux)-(?:aarch64|x86_64)$/u.test(executable)) return 'grok' + // 公式installerの更新先はgrok-1.0.41-linux-x86_64のように版を含む。 + if (/^grok-(?:\d+\.\d+\.\d+-)?(?:macos|linux)-(?:aarch64|x86_64)$/u.test(executable)) return 'grok' if (executable !== 'node' && executable !== 'node.exe') return null // Node直下の実entryだけを読む。shell -cや後続引数に含まれる名前は本人の根拠にしない。 const tokens = (identity.command ?? '').match(/"[^"]*"|'[^']*'|[^\s]+/gu) ?? [] From 590f5a8b65c0a00b7b0877495da2c6a65428101c Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:22:47 +0900 Subject: [PATCH 46/72] =?UTF-8?q?=E8=BF=BD=E5=8A=A0CLI=E5=8E=9F=E8=A8=BC?= =?UTF-8?q?=E6=8B=A0=E3=82=92=E4=BF=9D=E5=AD=98=E3=81=97OS=E4=BF=AE?= =?UTF-8?q?=E7=90=86=E5=BE=8C=E3=81=AE=E5=8F=97=E5=85=A5=E3=81=A8=E5=85=AC?= =?UTF-8?q?=E9=96=8B=E7=8A=B6=E6=85=8B=E3=82=92=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plan_parent-native-delivery.md | 2 + rag/INDEX.md | 1 + rag/parent-delivery/cli-lifecycle-repairs.md | 10 + rag/parent-delivery/product-acceptance.json | 85 +-- .../release-candidate-checkpoint.json | 53 +- .../additional-cli-independent-review.json | 636 ++++++++++++++++++ .../017bf2c/cli-formal-review-017bf2c.json | 191 ++++++ .../live/darwin/claude/cli/audience.json | 497 ++++++++++++++ .../live/darwin/codex/cli/audience.json | 487 ++++++++++++++ .../live/linux/claude/cli/audience.json | 496 ++++++++++++++ .../live/win32/claude/cli/audience.json | 497 ++++++++++++++ .../live/win32/codex/cli/audience.json | 485 +++++++++++++ .../snapshots/017bf2c/product-acceptance.json | 87 +++ .../017bf2c/release-candidate-checkpoint.json | 51 ++ 14 files changed, 3475 insertions(+), 103 deletions(-) create mode 100644 rag/parent-delivery/snapshots/017bf2c/additional-cli-independent-review.json create mode 100644 rag/parent-delivery/snapshots/017bf2c/cli-formal-review-017bf2c.json create mode 100644 rag/parent-delivery/snapshots/017bf2c/live/darwin/claude/cli/audience.json create mode 100644 rag/parent-delivery/snapshots/017bf2c/live/darwin/codex/cli/audience.json create mode 100644 rag/parent-delivery/snapshots/017bf2c/live/linux/claude/cli/audience.json create mode 100644 rag/parent-delivery/snapshots/017bf2c/live/win32/claude/cli/audience.json create mode 100644 rag/parent-delivery/snapshots/017bf2c/live/win32/codex/cli/audience.json create mode 100644 rag/parent-delivery/snapshots/017bf2c/product-acceptance.json create mode 100644 rag/parent-delivery/snapshots/017bf2c/release-candidate-checkpoint.json diff --git a/docs/plan_parent-native-delivery.md b/docs/plan_parent-native-delivery.md index 18741866..f34cdd49 100644 --- a/docs/plan_parent-native-delivery.md +++ b/docs/plan_parent-native-delivery.md @@ -389,3 +389,5 @@ Codexの公式hook本文はXMLのtextとして記録される。[一次仕様の 修理後の配布候補は[最新チェックポイント](../rag/parent-delivery/release-candidate-checkpoint.json)に固定した。macOS・Linux・Windows nativeの製品CIは全て合格し、131 fileのpackに新しい起動adapterが含まれ、試験・RAG・ツールの一時状態は含まれないことを確認した。packをmacOSの専用local prefixへnpmで導入し、版・runtime digestと診断readyを確認した。正式受入は修理後の再測定中で、旧候補の6件を新しいmanifestに数えない。registry版のglobal installと本番反映はまだ実施していない。 修理後のmacOS Claude/Codex通常CLIのaudienceは、[独立監査](../rag/parent-delivery/cli-formal-review-017bf2c.json)で共通pack、controllerのGit blob、room原文、実会話の本文と後続返答、spool、SQLite receiptを照合した。各3通が一致し、終了後の自動停止と設定解除も確認できたため、現在の受入manifestへ実測sourceを維持して追加した。短期scenarioの実作業file拡張子と記録反映の競合はcontroller側の修理中であり、失敗した項目を合格に数えない。長時間leaseと他OS・Desktop・IDEの受入は残る。 + +その後、macOSの短命process終了時にOSが返す成功・空出力を形式異常と誤分類する製品欠陥を再現し、OS adapterを修理した。Linux Grokの公式更新先binary名を認識しない欠陥も原因を確認して修理した。この製品差分により`017bf2c`の正式受入2件は[snapshot](../rag/parent-delivery/snapshots/017bf2c/product-acceptance.json)へ保存し、現行manifestを0件へ戻した。旧sourceのWindows Claude/Codex・Linux Claude各3通も独立照合済みだが、新しい製品sourceへ流用しない。長時間leaseは開始処理の欠陥で成立せず、長時間試験の私物processの回収を確認した。原因・packのmode差・controller修理の現在地は[CLI修理記録](../rag/parent-delivery/cli-lifecycle-repairs.md)を参照する。新候補の固定、3 OS製品CI、実機受入、main着地、npm公開・registry導入・本番反映は残る。 diff --git a/rag/INDEX.md b/rag/INDEX.md index 0edc94b4..521438be 100644 --- a/rag/INDEX.md +++ b/rag/INDEX.md @@ -7,3 +7,4 @@ - [Cursor/Grokの正式CLI証拠の親監査](parent-delivery/cursor-grok-formal-review.json) — 旧候補の6 audience、18本文と公式turn・後続返答の独立照合、画面取得の不足。 - [CLIの終了・設定解除・本人相関の修理](parent-delivery/cli-lifecycle-repairs.md) — Linuxの削除済みbinary、Windows Codex Job、公式設定解除とJSON構造、旧証跡の保存と再測定条件。 - [修理後候補のCLI正式証拠の独立監査](parent-delivery/cli-formal-review-017bf2c.json) — macOS Claude/Codexのaudience原文・実会話・receipt・controller・pack・終了回収の照合。 +- [旧sourceのWindows・Linux CLI追加監査](parent-delivery/snapshots/017bf2c/additional-cli-independent-review.json) — 各3通の実会話・receipt照合と、Windows packのmode差。製品修理後の候補には算入しない。 diff --git a/rag/parent-delivery/cli-lifecycle-repairs.md b/rag/parent-delivery/cli-lifecycle-repairs.md index 80f397eb..0b597178 100644 --- a/rag/parent-delivery/cli-lifecycle-repairs.md +++ b/rag/parent-delivery/cli-lifecycle-repairs.md @@ -28,6 +28,16 @@ runnerの空eventを除く意味比較は廃止した。Codexの試験dir信頼 ## 本人相関と試験の再開条件 +macOSのCursor通常CLI試験では、Throughlineの短命なprompt-submit用`/bin/zsh`を走査する間に終了競合を再現した。元の`ps`は開始identityを正常に返し、3ms後の`lsof`は終了code 0・空出力を返した。25ms後の両APIの再照会では本人が消失していた。旧adapterは成功した空出力を`PARENT_PROCESS_API_SCHEMA_INVALID`として伝播していた。 + +OS adapterは形式異常でも本人の消失を`ps`で確認し、確認できた場合だけ終了を返す。存命中の形式異常と、生存確認APIの権限異常は引き続きtyped errorにする。macOS境界のfocused 2件と修理moduleを使った実Cursor join・自己回収を確認した。実joinの再試行では同じ終了競合自体の再発火は観測していないため、正式受入合格としては扱わない。 + +Linux Grokは公式installerの実体名`grok-1.0.41-linux-x86_64`を旧判定が認識せず、事前hookが相関なしの`{}`を返し、実MCPのjoinが`PARENT_CALLER_UNBOUND`で失敗した。同じ公式実体の再起動で、実PID・開始identity・実pathと判定`null`を確認した。失敗当時の`/proc`原記録は取得できていない。版なしの公式実体名に加えて版を含む公式命名形式を認識し、未知のOS名は引き続き拒否する。正式Linux配送は新候補で再測定する。 + +修理前`017bf2c`の受入manifest・Mac2件の原証拠は[snapshot](snapshots/017bf2c/product-acceptance.json)へbyte単位で保存した。[追加3件の独立監査](snapshots/017bf2c/additional-cli-independent-review.json)はWindows Claude/Codex・Linux Claudeのroom原文、実会話、後続返答、spool、SQLite receipt、controllerのGit blobを照合した。各3通は一致した。Linux packの全131entryは凍結packと本文・modeとも一致した。Windows packの全file本文は一致したが27fileのmodeが異なり、圧縮byte列だけの差ではなかった。いずれも修理後の候補へ流用しない。 + +長時間leaseはMac Claudeの公式slotまで成立したが、controllerのpath関数とendpoint再join関数の名前衝突で開始処理が失敗し、24時間の検証は成立しなかった。Mac Cursor/GrokとWindows Cursor/Grokも準備段階で失敗し、Linux Cursorは未ログインで開始できなかった。私物processは回収済みで、長時間受信の合格は0件である。controllerの名前衝突、Grokの完了task全文reader入力、実応答・途中page・Codex task完了記録を待つ処理を修理し、正式試験と区別してfocused確認した。 + Claude/Cursor/Grokの事前hookはharness・名前・入力digest・PID開始identityを照合する。照合記録の30秒期限切れは`PARENT_BIND_TIMEOUT`として本人不一致と区別し、endpoint作成前に失敗する。Codexは公式thread metadataと実processを直接照合する。 旧候補`43348b8`の6 audienceと18本文の独立監査は、元のsource・本文・判定を保った[snapshot](snapshots/43348b8/rag/parent-delivery/product-acceptance.json)へ保存した。現行manifestに算入しない。修理後の配布sourceと試験controllerをcommitへ固定し、Git blob・digest・版を照合して全必須面の正式試験を行う。main着地、npm公開、registry導入、本番反映はまだ行っていない。 diff --git a/rag/parent-delivery/product-acceptance.json b/rag/parent-delivery/product-acceptance.json index 37481dd3..1f6e357d 100644 --- a/rag/parent-delivery/product-acceptance.json +++ b/rag/parent-delivery/product-acceptance.json @@ -1,87 +1,4 @@ { "schema": "peertable.parent-product-acceptance.v1", - "records": [ - { - "id": "darwin/claude/cli/audience", - "status": "passed", - "kind": "product_live", - "native": true, - "harness_version": "2.1.284 (Claude Code)", - "source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", - "runtime_digest": "aab616d50a6a0ec7752f492e1f28cfa73f6afb501b31a3d3eae1041584bfc2f2", - "package_version": "0.8.63", - "parent_session": "d9481638-df40-460f-8d51-fa3feb29a8f5", - "evidence_file": "rag/parent-delivery/live/darwin/claude/cli/audience.json", - "body_equal": true, - "owner_input_required": false, - "receipt_evidence": "3:bell:r1,4:bell:r1,5:bell:r1", - "observations": [ - "parent_join", - "delivery_in_parent_conversation", - "parent_reply_after_delivery", - "delivery_in_parent_conversation", - "parent_reply_after_delivery", - "delivery_in_parent_conversation", - "parent_reply_after_delivery" - ], - "audiences": { - "dm": { - "count": 1, - "body_equal": true, - "status": "passed" - }, - "multiple": { - "count": 1, - "body_equal": true, - "status": "passed" - }, - "all": { - "count": 1, - "body_equal": true, - "status": "passed" - } - } - }, - { - "id": "darwin/codex/cli/audience", - "status": "passed", - "kind": "product_live", - "native": true, - "harness_version": "codex-cli 0.155.1", - "source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", - "runtime_digest": "aab616d50a6a0ec7752f492e1f28cfa73f6afb501b31a3d3eae1041584bfc2f2", - "package_version": "0.8.63", - "parent_session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", - "evidence_file": "rag/parent-delivery/live/darwin/codex/cli/audience.json", - "body_equal": true, - "owner_input_required": false, - "receipt_evidence": "3:bell:r2,4:bell:r1,5:bell:r1", - "observations": [ - "parent_join", - "delivery_in_parent_conversation", - "parent_reply_after_delivery", - "delivery_in_parent_conversation", - "parent_reply_after_delivery", - "delivery_in_parent_conversation", - "parent_reply_after_delivery" - ], - "audiences": { - "dm": { - "count": 1, - "body_equal": true, - "status": "passed" - }, - "multiple": { - "count": 1, - "body_equal": true, - "status": "passed" - }, - "all": { - "count": 1, - "body_equal": true, - "status": "passed" - } - } - } - ] + "records": [] } diff --git a/rag/parent-delivery/release-candidate-checkpoint.json b/rag/parent-delivery/release-candidate-checkpoint.json index 264ccb99..0b336632 100644 --- a/rag/parent-delivery/release-candidate-checkpoint.json +++ b/rag/parent-delivery/release-candidate-checkpoint.json @@ -1,16 +1,32 @@ { "schema": "peertable.parent-release-checkpoint.v1", - "source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", - "runtime_digest": "aab616d50a6a0ec7752f492e1f28cfa73f6afb501b31a3d3eae1041584bfc2f2", + "source_commit": "356f9c408bfb8a46327651130b513b21b1b2842a", + "runtime_digest": "1fd30ff226657e52a40416e2f9b2699631aecaef03307a4b34449cc0787647db", "package_version": "0.8.63", + "candidate_state": "repairing_before_source_freeze", + "source_frozen": false, + "previous_candidate_checkpoint": "rag/parent-delivery/snapshots/017bf2c/release-candidate-checkpoint.json", "product_ci": { - "url": "https://github.com/kitepon/peertable/actions/runs/36473398201", - "head_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", - "status": "success", - "native_os": { - "darwin": "passed", - "linux": "passed", - "win32": "passed" + "status": "pending_new_product_source", + "previous_source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "previous_url": "https://github.com/kitepon/peertable/actions/runs/36473398201" + }, + "focused_verification": { + "darwin_process_exit": { + "passed": 2, + "failed": 0 + }, + "parent_delivery_and_harness_identity": { + "passed": 29, + "failed": 0 + }, + "native_fixture_and_lease_entry": { + "passed": 24, + "failed": 0 + }, + "scenario_transcript_observation": { + "passed": 22, + "failed": 0 } }, "documentation_tests": { @@ -19,26 +35,25 @@ "skipped": 0 }, "package": { - "filename": "peertable-0.8.63.tgz", - "sha256": "9094a3a53b4d66b6322c9ca1e4f23e93810a760d79872b862fe73e26b85dd774", - "pack_dry_run_files": 131, - "pack_files": 131, - "parent_process_included": true, - "install_source": "npm_pack_tarball", - "install_scope": "fixture_local_prefix", - "diagnostics": "ready", + "status": "pending_new_product_source", + "previous_candidate_only": true, "registry_install": false }, "product_acceptance": { "manifest": "rag/parent-delivery/product-acceptance.json", "status": "failed", "expected": 525, - "records": 2, + "records": 0, "errors": { - "PARENT_ACCEPTANCE_MISSING": 523 + "PARENT_ACCEPTANCE_MISSING": 525 }, "checkpoint_counts_as_live_acceptance": false }, + "lease": { + "status": "no_active_long_lease", + "passed": 0, + "previous_attempts": "failed_before_24h_wait" + }, "gui_observation": { "status": "blocked", "tool": "jev-desktop", diff --git a/rag/parent-delivery/snapshots/017bf2c/additional-cli-independent-review.json b/rag/parent-delivery/snapshots/017bf2c/additional-cli-independent-review.json new file mode 100644 index 00000000..4f003e60 --- /dev/null +++ b/rag/parent-delivery/snapshots/017bf2c/additional-cli-independent-review.json @@ -0,0 +1,636 @@ +{ + "schema": "peertable.parent-independent-review.v1", + "reports": [ + { + "case_id": "win32/claude/cli/audience", + "status": "passed", + "source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "evidence_path": "rag/parent-delivery/snapshots/017bf2c/live/win32/claude/cli/audience.json", + "runner_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "package_tarball_sha256": "b68ff9a9744ff38ef20065b73fd6273cd670426a96d4a664be612c3f45ef5cd2", + "raw_transcript_sha256": "9a827eac536db392737d6ac95037c0df3314064ed511fd4652337e906fc9c5bb", + "raw_spool_sha256": "3bb6964d70687af1ebcfcf7f626e56e6e90cb14c558a80be2100897918f5da68", + "checks": [ + { + "label": "dm", + "seq": 3, + "delivery_id": "1d7105f9-982d-4976-b955-5158d833759e", + "turn_id": "2db14ae3-d6cd-4085-8ea6-2b3fe5ec30b7", + "reply_turn_id": "2db14ae3-d6cd-4085-8ea6-2b3fe5ec30b7", + "body_sha256": "7b4bf5c6196b415588091ded313f59734a1b183c4ac31e897de7add6faf6b104", + "body_equal": true, + "receipt_revision": 1 + }, + { + "label": "multiple", + "seq": 4, + "delivery_id": "7a6b3ae5-a4cc-4fc6-9ec8-f3676a645cea", + "turn_id": "95545bcd-fa86-4f02-b3a9-c706d2cdf9a3", + "reply_turn_id": "95545bcd-fa86-4f02-b3a9-c706d2cdf9a3", + "body_sha256": "f9d186ea505718b78dc6c79f595607a6e0187e9a89ca8a23c53c984d55a5ef0f", + "body_equal": true, + "receipt_revision": 1 + }, + { + "label": "all", + "seq": 5, + "delivery_id": "d6e4ad4b-8ee9-4a2d-86f0-cc5949a11ad2", + "turn_id": "68622e5c-f437-4727-a24d-54750785d5c8", + "reply_turn_id": "68622e5c-f437-4727-a24d-54750785d5c8", + "body_sha256": "2d037a8fe3b0fc7da60e44ae39246b035b6a93d2eea199d4bf50436548d87473", + "body_equal": true, + "receipt_revision": 1 + } + ], + "cleanup": [ + { + "label": "harness_exit", + "status": "done", + "pid": 41340, + "exited": true + }, + { + "label": "endpoint_stop", + "status": "done", + "product_index_left_after_stop": false, + "runtime_before": "stopped", + "runtime_after": "stopped", + "product_stopped_within_30s": true, + "product_stop_latency_ms": 1, + "stopped_by_runner": false, + "owned_alive_after": [] + }, + { + "label": "pty_close", + "status": "done", + "session": "ptacc-claude-2c02583a", + "outcome": "closed", + "pane_pid": 70816, + "pane_alive_after": false + }, + { + "label": "room_server", + "status": "done", + "pid": 55632, + "exit": "SIGTERM", + "alive_after": false + }, + { + "label": "connect_remove", + "status": "done", + "removed": [ + "claude:removed" + ], + "semantic_equal": true, + "settings_diff_keys": [], + "mcp_diff_keys": [], + "user_keys_added": [], + "backup": null + } + ], + "negative_delivery_scope": "当該audience原証拠には他人宛DM/self投稿は含まれていない", + "package_comparison": { + "sha256": "b68ff9a9744ff38ef20065b73fd6273cd670426a96d4a664be612c3f45ef5cd2", + "entries": 131, + "content_equal": true, + "mode_equal": false, + "differences": { + "package/skill/scripts/seat-status-bridge.mjs": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/codex-parent-watch.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/ensure-all-bridges.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/leave-seat.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/set-mission.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/launch-seat.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/teardown.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/ensure-project-runtime.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/setup.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/resolve-seat-placement.mjs": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/ensure-bridge.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/resume.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/room/client.mjs": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/bridge-record-live.mjs": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/make-plan-input.mjs": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/kickoff-gate.mjs": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/change-effort.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/archive-room-log.py": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/external-pane.mjs": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/ensure-codex-room-mcp.mjs": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/alarm-set.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/ensure-room-mcp.mjs": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/doctor.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/parent-join.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/upgrade-team-assets.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/wakeup-bridge.mjs": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/change-seat.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + } + } + }, + "current_candidate_accepted": false, + "note": "製品修理前の実観測。Windows packは全file本文が一致するが27 fileのmodeが凍結packと異なる。現行候補へ流用しない。" + }, + { + "case_id": "win32/codex/cli/audience", + "status": "passed", + "source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "evidence_path": "rag/parent-delivery/snapshots/017bf2c/live/win32/codex/cli/audience.json", + "runner_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "package_tarball_sha256": "b68ff9a9744ff38ef20065b73fd6273cd670426a96d4a664be612c3f45ef5cd2", + "raw_transcript_sha256": "c241983f448fdad35f2a4f29d7768f873f93ecb5bc58c30d3be8b59fbe0a5573", + "raw_spool_sha256": "ccdc022e38452b4ca5978aa5d1ceca12c0890c9ea226d4d37664eb213eb7f488", + "checks": [ + { + "label": "dm", + "seq": 3, + "delivery_id": "07ccae7b-d892-4475-bc5d-8df9e0a8b046", + "turn_id": "01a0e98c-8710-7402-b313-45c3b4ff9508", + "reply_turn_id": "01a0e98c-8710-7402-b313-45c3b4ff9508", + "body_sha256": "18236de68e208590ade8bff102e3ed6ace47a727140498df1b458922f8bec792", + "body_equal": true, + "receipt_revision": 2 + }, + { + "label": "multiple", + "seq": 4, + "delivery_id": "a080fc61-f4ef-433b-92ca-2c5d3c629d5f", + "turn_id": "01a0e98c-8710-7402-b313-45c3b4ff9508", + "reply_turn_id": "01a0e98c-8710-7402-b313-45c3b4ff9508", + "body_sha256": "bb8a98e7b738f635c0e37d15af8b64788d38e0acfab810de5e38ae53dd4ecb93", + "body_equal": true, + "receipt_revision": 2 + }, + { + "label": "all", + "seq": 5, + "delivery_id": "d7cc1862-c411-42da-8c31-69b9c31154d2", + "turn_id": "01a0e98c-8710-7402-b313-45c3b4ff9508", + "reply_turn_id": "01a0e98c-8710-7402-b313-45c3b4ff9508", + "body_sha256": "27e7fb16597b80adc2f393ef15deb1cae861b25ed7f07b7009e36f192a884a42", + "body_equal": true, + "receipt_revision": 2 + } + ], + "cleanup": [ + { + "label": "harness_exit", + "status": "done", + "pid": 67192, + "exited": true + }, + { + "label": "endpoint_stop", + "status": "done", + "product_index_left_after_stop": false, + "runtime_before": "stopped", + "runtime_after": "stopped", + "product_stopped_within_30s": true, + "product_stop_latency_ms": 11286, + "stopped_by_runner": false, + "owned_alive_after": [] + }, + { + "label": "codex_folder_trust_remove", + "status": "done", + "removed_trust_entries": 1, + "remaining_trust_entries": 0 + }, + { + "label": "pty_close", + "status": "done", + "session": "ptacc-codex-96548ea4", + "outcome": "closed", + "pane_pid": 55880, + "pane_alive_after": false + }, + { + "label": "room_server", + "status": "done", + "pid": 50072, + "exit": "SIGTERM", + "alive_after": false + }, + { + "label": "connect_remove", + "status": "done", + "removed": [ + "codex:removed" + ], + "semantic_equal": true, + "effective_diff_keys": [], + "hooks_diff_keys": [], + "config_text_equal": true, + "backup": null + } + ], + "negative_delivery_scope": "当該audience原証拠には他人宛DM/self投稿は含まれていない", + "package_comparison": { + "sha256": "b68ff9a9744ff38ef20065b73fd6273cd670426a96d4a664be612c3f45ef5cd2", + "entries": 131, + "content_equal": true, + "mode_equal": false, + "differences": { + "package/skill/scripts/seat-status-bridge.mjs": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/codex-parent-watch.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/ensure-all-bridges.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/leave-seat.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/set-mission.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/launch-seat.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/teardown.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/ensure-project-runtime.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/setup.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/resolve-seat-placement.mjs": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/ensure-bridge.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/resume.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/room/client.mjs": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/bridge-record-live.mjs": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/make-plan-input.mjs": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/kickoff-gate.mjs": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/change-effort.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/archive-room-log.py": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/external-pane.mjs": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/ensure-codex-room-mcp.mjs": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/alarm-set.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/ensure-room-mcp.mjs": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/doctor.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/parent-join.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/upgrade-team-assets.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/wakeup-bridge.mjs": { + "mode": { + "frozen": 493, + "actual": 420 + } + }, + "package/skill/scripts/change-seat.sh": { + "mode": { + "frozen": 493, + "actual": 420 + } + } + } + }, + "current_candidate_accepted": false, + "note": "製品修理前の実観測。Windows packは全file本文が一致するが27 fileのmodeが凍結packと異なる。現行候補へ流用しない。" + }, + { + "case_id": "linux/claude/cli/audience", + "status": "passed", + "source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "evidence_path": "rag/parent-delivery/snapshots/017bf2c/live/linux/claude/cli/audience.json", + "runner_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "package_tarball_sha256": "5bb34d5512b18b4ad9c920c707002b8f04d694f0643eec3766ca97e0da1ffc2e", + "raw_transcript_sha256": "f02badbe34ad3f96514a0c8e7f4c3ed10ca230d706dfb0d5f1ba85341a0bdd67", + "raw_spool_sha256": "39efcbe76c0745e0969697c3f785c82b9e9791b38eb9b65f6742c50aa75bb1df", + "checks": [ + { + "label": "dm", + "seq": 3, + "delivery_id": "5675de08-24f3-494e-9980-1b4221188815", + "turn_id": "3258827a-eed5-4f5e-aa87-c10ff129c73a", + "reply_turn_id": "3258827a-eed5-4f5e-aa87-c10ff129c73a", + "body_sha256": "0add03d9a4983db57ce0d3dd4513642652aa90aae7be41617ea9cc489c856533", + "body_equal": true, + "receipt_revision": 1 + }, + { + "label": "multiple", + "seq": 4, + "delivery_id": "adc3925e-b1cf-489d-89dc-07423d07f8ca", + "turn_id": "3a7ea80d-4728-40b0-9999-ed35f58d5fe4", + "reply_turn_id": "3a7ea80d-4728-40b0-9999-ed35f58d5fe4", + "body_sha256": "10a9194262bf563161297f59335da92b791ba7feb89442f10ae8a7489976a74e", + "body_equal": true, + "receipt_revision": 1 + }, + { + "label": "all", + "seq": 5, + "delivery_id": "2de14aeb-0b52-4ba4-b611-5974b59ba005", + "turn_id": "8408518c-e972-40a9-955e-0bc0fe1c042e", + "reply_turn_id": "8408518c-e972-40a9-955e-0bc0fe1c042e", + "body_sha256": "051b3c29a224db65cbce9d649bdff41e3b962347f904152304168b226a44030a", + "body_equal": true, + "receipt_revision": 1 + } + ], + "cleanup": [ + { + "label": "harness_exit", + "status": "done", + "pid": 4011965, + "exited": true + }, + { + "label": "endpoint_stop", + "status": "done", + "product_index_left_after_stop": false, + "runtime_before": "stopped", + "runtime_after": "stopped", + "product_stopped_within_30s": true, + "product_stop_latency_ms": 1, + "stopped_by_runner": false, + "owned_alive_after": [] + }, + { + "label": "pty_close", + "status": "done", + "session": "ptacc-claude-887baec0", + "outcome": "already_closed", + "pane_pid": 4011965, + "pane_alive_after": false + }, + { + "label": "room_server", + "status": "done", + "pid": 4011943, + "exit": "SIGTERM", + "alive_after": false + }, + { + "label": "connect_remove", + "status": "done", + "removed": [ + "claude:removed" + ], + "semantic_equal": true, + "settings_diff_keys": [], + "mcp_diff_keys": [], + "user_keys_added": [ + "tipsHistoryByCommand" + ], + "backup": null + } + ], + "negative_delivery_scope": "当該audience原証拠には他人宛DM/self投稿は含まれていない", + "package_comparison": { + "sha256": "5bb34d5512b18b4ad9c920c707002b8f04d694f0643eec3766ca97e0da1ffc2e", + "entries": 131, + "content_equal": true, + "mode_equal": true, + "differences": {} + }, + "current_candidate_accepted": false, + "note": "製品修理前の実観測。現行候補へ流用しない。" + } + ], + "frozen_pack_sha256": "9094a3a53b4d66b6322c9ca1e4f23e93810a760d79872b862fe73e26b85dd774", + "source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "current_candidate_accepted": false +} diff --git a/rag/parent-delivery/snapshots/017bf2c/cli-formal-review-017bf2c.json b/rag/parent-delivery/snapshots/017bf2c/cli-formal-review-017bf2c.json new file mode 100644 index 00000000..734960a1 --- /dev/null +++ b/rag/parent-delivery/snapshots/017bf2c/cli-formal-review-017bf2c.json @@ -0,0 +1,191 @@ +{ + "schema": "peertable.parent-independent-review.v1", + "reports": [ + { + "case_id": "darwin/claude/cli/audience", + "status": "passed", + "source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "evidence_path": "/private/tmp/claude-501/-Users-kite--codex-worktrees-parent-acceptance-runner-peertable/6578ec5b-7de6-4c99-87a4-7a9b9114360b/scratchpad/acc/mac/audience-claude/public/rag/parent-delivery/live/darwin/claude/cli/audience.json", + "runner_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "package_tarball_sha256": "9094a3a53b4d66b6322c9ca1e4f23e93810a760d79872b862fe73e26b85dd774", + "raw_transcript_sha256": "1d97efe793aeacf628a7ef4c660b909d8039134d3ed584e63c6bbd7b4ed8d372", + "raw_spool_sha256": "481b6710846140ae30f59e6c12b58dcc409813b32f4caa4e2727d35a3330e2e5", + "checks": [ + { + "label": "dm", + "seq": 3, + "delivery_id": "2f854a38-f45a-4a0f-b488-90ff6abc6665", + "turn_id": "87119b43-826d-4e3d-8307-89be6efc2216", + "reply_turn_id": "87119b43-826d-4e3d-8307-89be6efc2216", + "body_sha256": "3c54e47647eea0d5ac32c421f1b1921326ddf8fe3cc12f0b204169aa4888e663", + "body_equal": true, + "receipt_revision": 1 + }, + { + "label": "multiple", + "seq": 4, + "delivery_id": "decc001f-b62f-4b09-bc46-2469d27336b6", + "turn_id": "a69598ae-770d-45be-905a-fa09abca2b05", + "reply_turn_id": "a69598ae-770d-45be-905a-fa09abca2b05", + "body_sha256": "a89ddf33a4727b0f7a2c74887a36a2d3faf3ad11b9ee4b39c71f05186cdb3642", + "body_equal": true, + "receipt_revision": 1 + }, + { + "label": "all", + "seq": 5, + "delivery_id": "2e82da28-d24a-4ea3-a631-cf806e5f75fc", + "turn_id": "53613061-cb2f-4019-b2fe-075dce99b432", + "reply_turn_id": "53613061-cb2f-4019-b2fe-075dce99b432", + "body_sha256": "2eab1802ca8c7eed65473682210b499cda97e2c604627b9d69c984ec7fe2785c", + "body_equal": true, + "receipt_revision": 1 + } + ], + "cleanup": [ + { + "label": "harness_exit", + "status": "done", + "pid": 62478, + "exited": true + }, + { + "label": "endpoint_stop", + "status": "done", + "product_index_left_after_stop": false, + "runtime_before": "stopped", + "runtime_after": "stopped", + "product_stopped_within_30s": true, + "product_stop_latency_ms": 2, + "stopped_by_runner": false, + "owned_alive_after": [] + }, + { + "label": "pty_close", + "status": "done", + "session": "ptacc-claude-43776a08", + "outcome": "already_closed", + "pane_pid": 62478, + "pane_alive_after": false + }, + { + "label": "room_server", + "status": "done", + "pid": 62426, + "exit": "SIGTERM", + "alive_after": false + }, + { + "label": "connect_remove", + "status": "done", + "removed": [ + "claude:removed" + ], + "semantic_equal": true, + "settings_diff_keys": [], + "mcp_diff_keys": [], + "user_keys_added": [], + "backup": null + } + ], + "negative_delivery_scope": "当該audience原証拠には他人宛DM/self投稿は含まれていない" + }, + { + "case_id": "darwin/codex/cli/audience", + "status": "passed", + "source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "evidence_path": "/private/tmp/claude-501/-Users-kite--codex-worktrees-parent-acceptance-runner-peertable/6578ec5b-7de6-4c99-87a4-7a9b9114360b/scratchpad/acc/mac/audience-codex/public/rag/parent-delivery/live/darwin/codex/cli/audience.json", + "runner_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "package_tarball_sha256": "9094a3a53b4d66b6322c9ca1e4f23e93810a760d79872b862fe73e26b85dd774", + "raw_transcript_sha256": "180e13666945cffdba16725c23a74720dd61e6a9fcc28815fe3c1f25e53d8856", + "raw_spool_sha256": "d298a3deca56740a13707ae7bcc758924f7a4fddaee857f7f5e81831c967e502", + "checks": [ + { + "label": "dm", + "seq": 3, + "delivery_id": "e3df1dd7-c145-442e-83a4-440fbef4aaaf", + "turn_id": "01a0e989-44fe-7131-80ca-8607061db73f", + "reply_turn_id": "01a0e989-44fe-7131-80ca-8607061db73f", + "body_sha256": "49c46447eac89564a78b66b3a657f78918930f5f39ba5747f6542b2f0c231c42", + "body_equal": true, + "receipt_revision": 2 + }, + { + "label": "multiple", + "seq": 4, + "delivery_id": "5acdfa56-7a2a-4d52-84c2-7ee5966f1fa4", + "turn_id": "01a0e989-bb2a-7580-bb6e-0c985c492b3f", + "reply_turn_id": "01a0e989-bb2a-7580-bb6e-0c985c492b3f", + "body_sha256": "0543f36241800ba6ceba06125fb2f95b4f874c309c69d58bcf0bd56a1a9bae64", + "body_equal": true, + "receipt_revision": 1 + }, + { + "label": "all", + "seq": 5, + "delivery_id": "0be9d2d6-be47-4946-a59e-551bd54c0ccf", + "turn_id": "01a0e989-e247-7713-b3a7-097cca48d1cf", + "reply_turn_id": "01a0e989-e247-7713-b3a7-097cca48d1cf", + "body_sha256": "13e2ff50357eb842540525a44f86031b7ef72fe90da563deffc142fd0a801718", + "body_equal": true, + "receipt_revision": 1 + } + ], + "cleanup": [ + { + "label": "harness_exit", + "status": "done", + "pid": 66124, + "exited": true + }, + { + "label": "endpoint_stop", + "status": "done", + "product_index_left_after_stop": false, + "runtime_before": "stopped", + "runtime_after": "stopped", + "product_stopped_within_30s": true, + "product_stop_latency_ms": 7314, + "stopped_by_runner": false, + "owned_alive_after": [] + }, + { + "label": "codex_folder_trust_remove", + "status": "done", + "removed_trust_entries": 1, + "remaining_trust_entries": 0 + }, + { + "label": "pty_close", + "status": "done", + "session": "ptacc-codex-45a0c8fc", + "outcome": "already_closed", + "pane_pid": 66124, + "pane_alive_after": false + }, + { + "label": "room_server", + "status": "done", + "pid": 66072, + "exit": "SIGTERM", + "alive_after": false + }, + { + "label": "connect_remove", + "status": "done", + "removed": [ + "codex:removed" + ], + "semantic_equal": true, + "effective_diff_keys": [], + "hooks_diff_keys": [], + "config_text_equal": true, + "backup": null + } + ], + "negative_delivery_scope": "当該audience原証拠には他人宛DM/self投稿は含まれていない" + } + ], + "source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "scope": "macOS Claude/Codex通常CLIのaudience。原証拠から本文・会話・receipt・controller・pack・自己停止を独立照合した。全525項目の合格ではない。" +} diff --git a/rag/parent-delivery/snapshots/017bf2c/live/darwin/claude/cli/audience.json b/rag/parent-delivery/snapshots/017bf2c/live/darwin/claude/cli/audience.json new file mode 100644 index 00000000..19870692 --- /dev/null +++ b/rag/parent-delivery/snapshots/017bf2c/live/darwin/claude/cli/audience.json @@ -0,0 +1,497 @@ +{ + "schema": "peertable.parent-live-case.v1", + "case_id": "darwin/claude/cli/audience", + "status": "passed", + "reasons": [], + "native": true, + "os": "darwin", + "harness": "claude", + "surface": "cli", + "source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "runtime_digest": "aab616d50a6a0ec7752f492e1f28cfa73f6afb501b31a3d3eae1041584bfc2f2", + "package_version": "0.8.63", + "client_version": "0.8.63", + "package_tarball_sha256": "9094a3a53b4d66b6322c9ca1e4f23e93810a760d79872b862fe73e26b85dd774", + "harness_version": "2.1.284 (Claude Code)", + "node_version": "v26.10.0", + "parent_session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "parent_process": { + "pid": 62478, + "parent": 1199, + "started": "Tue Sep 29 04:38:59 2026", + "executable": "/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe", + "command": "claude --model claude-sonnet-5-5" + }, + "endpoint_id": "6fdf6a52-3a3b-48fa-8c76-d90ef8d18bd3", + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "owner_input_required": false, + "isolation": { + "home": "normal", + "room": "local_private_room_server", + "session_args": [], + "folder_trust": "起動dialogで試験dirを信頼(Claudeのproject履歴に残る)" + }, + "controller": { + "files_sha256": { + "README.md": "b388692fe0b584dd88ef73180bb9cebd5bf8e20b22c38a930251c1873c2a4a10", + "aiterm.mjs": "c3fa88a1032455dd70698e567d0e364d4714a6c3afd96cff46d5aad6ab953b16", + "background-harness.mjs": "40614a98000ec761dc38c6ba54a114c744db450d7efa7bc67a1276ab9a5ddca0", + "evidence.mjs": "216a4667764bcd7b7c402e2d81353f5c3cba200f20a303b1b1abb80735280490", + "harness.mjs": "282d2bf7d8aff8493a05eaef9903e2eadda85e3d4ef45a7762242d0f209f3712", + "run.mjs": "1b0aad3d3be8bf2a430c6469559b3d00bd579ed6d8e0bb4eb3582a5e4b4fc1ac", + "runner-provenance.mjs": "ec44651161c6f27bad824c75102a299c0ce6502b8ab6b7f640114f6488e4ad74", + "scenarios-context.mjs": "aa569402743406df39d9ff4d0bdf50fe7dcbd1c4ff3f2dd1858be5c44aa403e5", + "scenarios-fixtures.mjs": "f4ad05cafcede3132c322fee4f768626b97275e051219cc70241a0286a1e0bf8", + "scenarios-lease-run.mjs": "6348a310b1f56a350eb6c38c3bcd6b729bef369da408229a1a837a55f024becc", + "scenarios-native.mjs": "cce9267bc404b41c89e56608888086b3efb4aa1ff89af934f510358dd41b42fe", + "scenarios-surfaces.mjs": "bf5fab85bbdb8d50f4182d0db635779c0ced280eb0fda7ddcb28423da5906ea8", + "scenarios.mjs": "08db776c879b02b8da7b6263511256b192d35a6d78fdaf1164e05328184fca98" + } + }, + "runner": { + "commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "modules_sha256": { + "README.md": "b388692fe0b584dd88ef73180bb9cebd5bf8e20b22c38a930251c1873c2a4a10", + "aiterm.mjs": "c3fa88a1032455dd70698e567d0e364d4714a6c3afd96cff46d5aad6ab953b16", + "background-harness.mjs": "40614a98000ec761dc38c6ba54a114c744db450d7efa7bc67a1276ab9a5ddca0", + "evidence.mjs": "216a4667764bcd7b7c402e2d81353f5c3cba200f20a303b1b1abb80735280490", + "harness.mjs": "282d2bf7d8aff8493a05eaef9903e2eadda85e3d4ef45a7762242d0f209f3712", + "run.mjs": "1b0aad3d3be8bf2a430c6469559b3d00bd579ed6d8e0bb4eb3582a5e4b4fc1ac", + "runner-provenance.mjs": "ec44651161c6f27bad824c75102a299c0ce6502b8ab6b7f640114f6488e4ad74", + "scenarios-context.mjs": "aa569402743406df39d9ff4d0bdf50fe7dcbd1c4ff3f2dd1858be5c44aa403e5", + "scenarios-fixtures.mjs": "f4ad05cafcede3132c322fee4f768626b97275e051219cc70241a0286a1e0bf8", + "scenarios-lease-run.mjs": "6348a310b1f56a350eb6c38c3bcd6b729bef369da408229a1a837a55f024becc", + "scenarios-native.mjs": "cce9267bc404b41c89e56608888086b3efb4aa1ff89af934f510358dd41b42fe", + "scenarios-surfaces.mjs": "bf5fab85bbdb8d50f4182d0db635779c0ced280eb0fda7ddcb28423da5906ea8", + "scenarios.mjs": "08db776c879b02b8da7b6263511256b192d35a6d78fdaf1164e05328184fca98" + }, + "product_source_is_separate": true + }, + "observations": [ + { + "kind": "parent_join", + "parent_session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "turn_id": "053a2451-649b-41e8-b6bf-d4511b33f96f", + "tool_use_id": "toolu_01WbrdxbF8UUaWaJxqdHZcik", + "tool_name": "mcp__peertable_parent__parent_join", + "endpoint_id": "6fdf6a52-3a3b-48fa-8c76-d90ef8d18bd3", + "caller_owner": { + "pid": 62478, + "parent": 1199, + "started": "Tue Sep 29 04:38:59 2026", + "executable": "/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe", + "command": "claude --model claude-sonnet-5-5" + }, + "call_generation": "febdffe9-4819-4504-a3b1-b96ca526497b" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "dm", + "parent_session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "turn_id": "87119b43-826d-4e3d-8307-89be6efc2216", + "seq": 3, + "delivery_id": "2f854a38-f45a-4a0f-b488-90ff6abc6665", + "entry": { + "uuid": "fdd08004-cb36-4e29-a3ef-7cfb344e5dac", + "type": "user", + "role": null + }, + "native_hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "native_queue": [ + "enqueue" + ], + "native_ack_id": "fdd08004-cb36-4e29-a3ef-7cfb344e5dac" + }, + { + "kind": "parent_reply_after_delivery", + "label": "dm", + "parent_session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "turn_id": "87119b43-826d-4e3d-8307-89be6efc2216", + "seq": 3, + "entry_uuid": "41bd9b94-1398-49e8-80ec-84ca534845fb" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "multiple", + "parent_session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "turn_id": "a69598ae-770d-45be-905a-fa09abca2b05", + "seq": 4, + "delivery_id": "decc001f-b62f-4b09-bc46-2469d27336b6", + "entry": { + "uuid": "28996c02-640f-44e4-bce6-ab939816a854", + "type": "user", + "role": null + }, + "native_hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "native_queue": [ + "enqueue" + ], + "native_ack_id": "28996c02-640f-44e4-bce6-ab939816a854" + }, + { + "kind": "parent_reply_after_delivery", + "label": "multiple", + "parent_session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "turn_id": "a69598ae-770d-45be-905a-fa09abca2b05", + "seq": 4, + "entry_uuid": "3989f69a-8589-4d86-943d-ae4a9ec50596" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "all", + "parent_session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "turn_id": "53613061-cb2f-4019-b2fe-075dce99b432", + "seq": 5, + "delivery_id": "2e82da28-d24a-4ea3-a631-cf806e5f75fc", + "entry": { + "uuid": "773c9a39-9edc-4db9-b1ee-8c54d5a099f9", + "type": "user", + "role": null + }, + "native_hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "native_queue": [ + "enqueue" + ], + "native_ack_id": "773c9a39-9edc-4db9-b1ee-8c54d5a099f9" + }, + { + "kind": "parent_reply_after_delivery", + "label": "all", + "parent_session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "turn_id": "53613061-cb2f-4019-b2fe-075dce99b432", + "seq": 5, + "entry_uuid": "0e63ce98-16eb-4221-9308-c5c1ff669a60" + } + ], + "receipts": [ + { + "seq": 3, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + } + ], + "body_checks": [ + { + "label": "dm", + "original": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_DM_d4de6a5e-0f50-4d8b-9450-d8fcdc47f1f4 です。" + }, + "received": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_DM_d4de6a5e-0f50-4d8b-9450-d8fcdc47f1f4 です。" + } + }, + { + "label": "multiple", + "original": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_d57504e7-1bfb-4f23-992a-a8d87f5ef5c4 です。" + }, + "received": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_d57504e7-1bfb-4f23-992a-a8d87f5ef5c4 です。" + } + }, + { + "label": "all", + "original": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_ALL_c9c170cc-9a1f-4b34-bd99-c334a97f5457 です。" + }, + "received": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_ALL_c9c170cc-9a1f-4b34-bd99-c334a97f5457 です。" + } + } + ], + "checks": [ + { + "status": "passed", + "reasons": [], + "label": "dm", + "recipient": "bell", + "original": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_DM_d4de6a5e-0f50-4d8b-9450-d8fcdc47f1f4 です。" + }, + "received": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_DM_d4de6a5e-0f50-4d8b-9450-d8fcdc47f1f4 です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_DM_d4de6a5e-0f50-4d8b-9450-d8fcdc47f1f4 です。", + "delivery_id": "2f854a38-f45a-4a0f-b488-90ff6abc6665", + "digest": "793e21ef9134ee83a59b92b61cb60f5572eb9ebb274aab621ae973caf6e63164", + "preview": false, + "order": 71, + "session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "turn_id": "87119b43-826d-4e3d-8307-89be6efc2216", + "hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "entry": { + "uuid": "fdd08004-cb36-4e29-a3ef-7cfb344e5dac", + "type": "user", + "role": null + } + }, + "reply": { + "order": 72, + "session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "turn_id": "87119b43-826d-4e3d-8307-89be6efc2216", + "entry_uuid": "41bd9b94-1398-49e8-80ec-84ca534845fb", + "text": "四件目の観測データを受信しました。配送確認の符号を含んでいます。\n\n- **符号**: `PEERTABLE_ACCEPT_CLAUDE_DM_d4de6a5e-0f50-4d8b-9450-d8fcdc47f1f4`\n- **送信者**: probe\n- **宛先**: bell\n- **seq**: 3\n- **本文**: 「日本語の観測」に続いて、引用符・絵文字・`>`・``・`&`・`'q'` を含む文字列\n- **room**: `parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34`\n- **配送ID**: `2f854a38-f45a-4a0f-b488-90ff6abc6665`\n\n本文の特殊文字は、そのまま表示された状態で届いています。行動命令はありません。" + }, + "spool": { + "delivery_id": "2f854a38-f45a-4a0f-b488-90ff6abc6665", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "claude_asyncRewake_output" + }, + "receipt": { + "seq": 3, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + } + }, + { + "status": "passed", + "reasons": [], + "label": "multiple", + "recipient": "bell", + "original": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_d57504e7-1bfb-4f23-992a-a8d87f5ef5c4 です。" + }, + "received": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_d57504e7-1bfb-4f23-992a-a8d87f5ef5c4 です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_d57504e7-1bfb-4f23-992a-a8d87f5ef5c4 です。", + "delivery_id": "decc001f-b62f-4b09-bc46-2469d27336b6", + "digest": "5d58304a692e68be2536e20ca6ba43dd23302a7de91e06772d9ba2d1505f6ce6", + "preview": false, + "order": 77, + "session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "turn_id": "a69598ae-770d-45be-905a-fa09abca2b05", + "hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "entry": { + "uuid": "28996c02-640f-44e4-bce6-ab939816a854", + "type": "user", + "role": null + } + }, + "reply": { + "order": 78, + "session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "turn_id": "a69598ae-770d-45be-905a-fa09abca2b05", + "entry_uuid": "3989f69a-8589-4d86-943d-ae4a9ec50596", + "text": "五件目の観測データを受信しました。配送確認の符号を含んでいます。\n\n- **符号**: `PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_d57504e7-1bfb-4f23-992a-a8d87f5ef5c4`\n- **送信者**: probe\n- **宛先**: bell と rui(複数宛)\n- **seq**: 4\n- **本文**: 前回と同じ特殊文字入りの「日本語の観測」\n- **room**: `parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34`\n- **配送ID**: `decc001f-b62f-4b09-bc46-2469d27336b6`\n\n行動命令はありません。" + }, + "spool": { + "delivery_id": "decc001f-b62f-4b09-bc46-2469d27336b6", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "claude_asyncRewake_output" + }, + "receipt": { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + } + }, + { + "status": "passed", + "reasons": [], + "label": "all", + "recipient": "bell", + "original": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_ALL_c9c170cc-9a1f-4b34-bd99-c334a97f5457 です。" + }, + "received": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_ALL_c9c170cc-9a1f-4b34-bd99-c334a97f5457 です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_ALL_c9c170cc-9a1f-4b34-bd99-c334a97f5457 です。", + "delivery_id": "2e82da28-d24a-4ea3-a631-cf806e5f75fc", + "digest": "1c8bfec362e650f6c06042980738222548fa5f8efbb753e58653f540bc2620af", + "preview": false, + "order": 83, + "session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "turn_id": "53613061-cb2f-4019-b2fe-075dce99b432", + "hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "entry": { + "uuid": "773c9a39-9edc-4db9-b1ee-8c54d5a099f9", + "type": "user", + "role": null + } + }, + "reply": { + "order": 84, + "session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "turn_id": "53613061-cb2f-4019-b2fe-075dce99b432", + "entry_uuid": "0e63ce98-16eb-4221-9308-c5c1ff669a60", + "text": "六件目の観測データを受信しました。配送確認の符号を含んでいます。\n\n- **符号**: `PEERTABLE_ACCEPT_CLAUDE_ALL_c9c170cc-9a1f-4b34-bd99-c334a97f5457`\n- **送信者**: probe\n- **宛先**: all(全員宛)\n- **seq**: 5\n- **本文**: 前回までと同じ特殊文字入りの「日本語の観測」\n- **room**: `parent-acceptance-claude-e3039502-41e1-49bf-bfac-aedb1383cb34`\n- **配送ID**: `2e82da28-d24a-4ea3-a631-cf806e5f75fc`\n\n行動命令はありません。" + }, + "spool": { + "delivery_id": "2e82da28-d24a-4ea3-a631-cf806e5f75fc", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "claude_asyncRewake_output" + }, + "receipt": { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + } + } + ], + "transcript_rows": 87, + "turns_seen": [ + "053a2451-649b-41e8-b6bf-d4511b33f96f", + "94dd6067-a541-404b-bdd1-c94d1d1d32f3", + "77d7a458-7e42-49a0-b543-ed1ca3af621c", + "23883d04-1a75-444e-9bf4-84f391af52ce", + "87119b43-826d-4e3d-8307-89be6efc2216", + "a69598ae-770d-45be-905a-fa09abca2b05", + "53613061-cb2f-4019-b2fe-075dce99b432" + ] +} diff --git a/rag/parent-delivery/snapshots/017bf2c/live/darwin/codex/cli/audience.json b/rag/parent-delivery/snapshots/017bf2c/live/darwin/codex/cli/audience.json new file mode 100644 index 00000000..db9b1bb6 --- /dev/null +++ b/rag/parent-delivery/snapshots/017bf2c/live/darwin/codex/cli/audience.json @@ -0,0 +1,487 @@ +{ + "schema": "peertable.parent-live-case.v1", + "case_id": "darwin/codex/cli/audience", + "status": "passed", + "reasons": [], + "native": true, + "os": "darwin", + "harness": "codex", + "surface": "cli", + "source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "runtime_digest": "aab616d50a6a0ec7752f492e1f28cfa73f6afb501b31a3d3eae1041584bfc2f2", + "package_version": "0.8.63", + "client_version": "0.8.63", + "package_tarball_sha256": "9094a3a53b4d66b6322c9ca1e4f23e93810a760d79872b862fe73e26b85dd774", + "harness_version": "codex-cli 0.155.1", + "node_version": "v26.10.0", + "parent_session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "parent_process": { + "pid": 66124, + "parent": 1199, + "started": "Tue Sep 29 04:41:12 2026", + "executable": "~/.codex/packages/standalone/releases/0.155.1-aarch64-apple-darwin/bin/codex", + "command": "codex -c mcp_servers.peertable_parent.env_vars=[\"PEERTABLE_TOKEN_SOURCE_FILE\"] -c check_for_update_on_startup=false -m gpt-6-sol" + }, + "endpoint_id": "f4029511-682a-4514-8b5a-f25335f59926", + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "owner_input_required": false, + "isolation": { + "home": "normal", + "room": "local_private_room_server", + "session_args": [ + "-c mcp_servers.peertable_parent.env_vars=[\"PEERTABLE_TOKEN_SOURCE_FILE\"]", + "-c check_for_update_on_startup=false" + ], + "folder_trust": "起動dialogで試験dirを信頼し、終了後に公式config/batchWriteでその projects entry だけを削除" + }, + "controller": { + "files_sha256": { + "README.md": "b388692fe0b584dd88ef73180bb9cebd5bf8e20b22c38a930251c1873c2a4a10", + "aiterm.mjs": "c3fa88a1032455dd70698e567d0e364d4714a6c3afd96cff46d5aad6ab953b16", + "background-harness.mjs": "40614a98000ec761dc38c6ba54a114c744db450d7efa7bc67a1276ab9a5ddca0", + "evidence.mjs": "216a4667764bcd7b7c402e2d81353f5c3cba200f20a303b1b1abb80735280490", + "harness.mjs": "282d2bf7d8aff8493a05eaef9903e2eadda85e3d4ef45a7762242d0f209f3712", + "run.mjs": "1b0aad3d3be8bf2a430c6469559b3d00bd579ed6d8e0bb4eb3582a5e4b4fc1ac", + "runner-provenance.mjs": "ec44651161c6f27bad824c75102a299c0ce6502b8ab6b7f640114f6488e4ad74", + "scenarios-context.mjs": "aa569402743406df39d9ff4d0bdf50fe7dcbd1c4ff3f2dd1858be5c44aa403e5", + "scenarios-fixtures.mjs": "f4ad05cafcede3132c322fee4f768626b97275e051219cc70241a0286a1e0bf8", + "scenarios-lease-run.mjs": "6348a310b1f56a350eb6c38c3bcd6b729bef369da408229a1a837a55f024becc", + "scenarios-native.mjs": "cce9267bc404b41c89e56608888086b3efb4aa1ff89af934f510358dd41b42fe", + "scenarios-surfaces.mjs": "bf5fab85bbdb8d50f4182d0db635779c0ced280eb0fda7ddcb28423da5906ea8", + "scenarios.mjs": "08db776c879b02b8da7b6263511256b192d35a6d78fdaf1164e05328184fca98" + } + }, + "runner": { + "commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "modules_sha256": { + "README.md": "b388692fe0b584dd88ef73180bb9cebd5bf8e20b22c38a930251c1873c2a4a10", + "aiterm.mjs": "c3fa88a1032455dd70698e567d0e364d4714a6c3afd96cff46d5aad6ab953b16", + "background-harness.mjs": "40614a98000ec761dc38c6ba54a114c744db450d7efa7bc67a1276ab9a5ddca0", + "evidence.mjs": "216a4667764bcd7b7c402e2d81353f5c3cba200f20a303b1b1abb80735280490", + "harness.mjs": "282d2bf7d8aff8493a05eaef9903e2eadda85e3d4ef45a7762242d0f209f3712", + "run.mjs": "1b0aad3d3be8bf2a430c6469559b3d00bd579ed6d8e0bb4eb3582a5e4b4fc1ac", + "runner-provenance.mjs": "ec44651161c6f27bad824c75102a299c0ce6502b8ab6b7f640114f6488e4ad74", + "scenarios-context.mjs": "aa569402743406df39d9ff4d0bdf50fe7dcbd1c4ff3f2dd1858be5c44aa403e5", + "scenarios-fixtures.mjs": "f4ad05cafcede3132c322fee4f768626b97275e051219cc70241a0286a1e0bf8", + "scenarios-lease-run.mjs": "6348a310b1f56a350eb6c38c3bcd6b729bef369da408229a1a837a55f024becc", + "scenarios-native.mjs": "cce9267bc404b41c89e56608888086b3efb4aa1ff89af934f510358dd41b42fe", + "scenarios-surfaces.mjs": "bf5fab85bbdb8d50f4182d0db635779c0ced280eb0fda7ddcb28423da5906ea8", + "scenarios.mjs": "08db776c879b02b8da7b6263511256b192d35a6d78fdaf1164e05328184fca98" + }, + "product_source_is_separate": true + }, + "observations": [ + { + "kind": "parent_join", + "parent_session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "turn_id": "01a0e989-44fe-7131-80ca-8607061db73f", + "tool_use_id": "2", + "tool_name": "output", + "endpoint_id": "f4029511-682a-4514-8b5a-f25335f59926", + "caller_owner": { + "pid": 66124, + "parent": 1199, + "started": "Tue Sep 29 04:41:12 2026", + "executable": "~/.codex/packages/standalone/releases/0.155.1-aarch64-apple-darwin/bin/codex", + "command": "codex -c mcp_servers.peertable_parent.env_vars=[\"PEERTABLE_TOKEN_SOURCE_FILE\"] -c check_for_update_on_startup=false -m gpt-6-sol" + }, + "call_generation": null + }, + { + "kind": "delivery_in_parent_conversation", + "label": "dm", + "parent_session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "turn_id": "01a0e989-44fe-7131-80ca-8607061db73f", + "seq": 3, + "delivery_id": "e3df1dd7-c145-442e-83a4-440fbef4aaaf", + "entry": { + "uuid": null, + "type": "response_item:message:user", + "role": "user" + }, + "native_hook": "stop", + "boundary": { + "encoding": "codex_hook_prompt_xml_text", + "raw_body_equal": false + }, + "native_queue": [], + "native_ack_id": "01a0e989-7441-7951-ab4f-19dc19832d73" + }, + { + "kind": "parent_reply_after_delivery", + "label": "dm", + "parent_session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "turn_id": "01a0e989-44fe-7131-80ca-8607061db73f", + "seq": 3, + "entry_uuid": null + }, + { + "kind": "delivery_in_parent_conversation", + "label": "multiple", + "parent_session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "turn_id": "01a0e989-bb2a-7580-bb6e-0c985c492b3f", + "seq": 4, + "delivery_id": "5acdfa56-7a2a-4d52-84c2-7ee5966f1fa4", + "entry": { + "uuid": null, + "type": "response_item:message:user", + "role": "user" + }, + "native_hook": null, + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "native_queue": [], + "native_ack_id": "01a0e989-b2f5-7c40-94e3-fd1867db1113" + }, + { + "kind": "parent_reply_after_delivery", + "label": "multiple", + "parent_session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "turn_id": "01a0e989-bb2a-7580-bb6e-0c985c492b3f", + "seq": 4, + "entry_uuid": null + }, + { + "kind": "delivery_in_parent_conversation", + "label": "all", + "parent_session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "turn_id": "01a0e989-e247-7713-b3a7-097cca48d1cf", + "seq": 5, + "delivery_id": "0be9d2d6-be47-4946-a59e-551bd54c0ccf", + "entry": { + "uuid": null, + "type": "response_item:message:user", + "role": "user" + }, + "native_hook": null, + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "native_queue": [], + "native_ack_id": "01a0e989-d277-75e1-ab0b-a63e86bec62f" + }, + { + "kind": "parent_reply_after_delivery", + "label": "all", + "parent_session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "turn_id": "01a0e989-e247-7713-b3a7-097cca48d1cf", + "seq": 5, + "entry_uuid": null + } + ], + "receipts": [ + { + "seq": 3, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "codex_hook_output_complete", + "receipt_revision": 2, + "queued_submission_id": "01a0e989-7441-7951-ab4f-19dc19832d73", + "accepted_at": "2026-09-28T19:41:34.658Z" + }, + { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "codex_queue_accepted", + "receipt_revision": 1, + "queued_submission_id": "01a0e989-b2f5-7c40-94e3-fd1867db1113", + "accepted_at": "2026-09-28T19:41:50.710Z" + }, + { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "codex_queue_accepted", + "receipt_revision": 1, + "queued_submission_id": "01a0e989-d277-75e1-ab0b-a63e86bec62f", + "accepted_at": "2026-09-28T19:41:58.776Z" + } + ], + "body_checks": [ + { + "label": "dm", + "original": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_DM_aac57c03-4a45-456b-8144-3ce1b2a1f51e です。" + }, + "received": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_DM_aac57c03-4a45-456b-8144-3ce1b2a1f51e です。" + } + }, + { + "label": "multiple", + "original": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_MULTIPLE_2531781f-bc9e-45a1-a995-ee32615ace9e です。" + }, + "received": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_MULTIPLE_2531781f-bc9e-45a1-a995-ee32615ace9e です。" + } + }, + { + "label": "all", + "original": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_ALL_d699f185-0e05-4f1a-b060-d22fcdc393b3 です。" + }, + "received": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_ALL_d699f185-0e05-4f1a-b060-d22fcdc393b3 です。" + } + } + ], + "checks": [ + { + "status": "passed", + "reasons": [], + "label": "dm", + "recipient": "bell", + "original": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_DM_aac57c03-4a45-456b-8144-3ce1b2a1f51e です。" + }, + "received": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_DM_aac57c03-4a45-456b-8144-3ce1b2a1f51e です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_DM_aac57c03-4a45-456b-8144-3ce1b2a1f51e です。", + "delivery_id": "e3df1dd7-c145-442e-83a4-440fbef4aaaf", + "digest": "cc9222d244a619511dc42ddcea5d2743de54b4bc8755d5072c3cec4c55040081", + "preview": false, + "order": 38, + "session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "turn_id": "01a0e989-44fe-7131-80ca-8607061db73f", + "hook": "stop", + "boundary": { + "encoding": "codex_hook_prompt_xml_text", + "raw_body_equal": false + }, + "entry": { + "uuid": null, + "type": "response_item:message:user", + "role": "user" + } + }, + "reply": { + "order": 41, + "session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "turn_id": "01a0e989-44fe-7131-80ca-8607061db73f", + "text": "- 配送確認の符号:`PEERTABLE_ACCEPT_CODEX_DM_aac57c03-4a45-456b-8144-3ce1b2a1f51e`\n- 送信者:`probe`\n- 宛先:`bell`\n- seq:`3`" + }, + "spool": { + "delivery_id": "e3df1dd7-c145-442e-83a4-440fbef4aaaf", + "state": "submitted", + "queued_submission_id": "01a0e989-7441-7951-ab4f-19dc19832d73", + "accepted_at": "2026-09-28T19:41:34.658Z", + "reason": "codex_hook_output_complete" + }, + "receipt": { + "seq": 3, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "codex_hook_output_complete", + "receipt_revision": 2, + "queued_submission_id": "01a0e989-7441-7951-ab4f-19dc19832d73", + "accepted_at": "2026-09-28T19:41:34.658Z" + } + }, + { + "status": "passed", + "reasons": [], + "label": "multiple", + "recipient": "bell", + "original": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_MULTIPLE_2531781f-bc9e-45a1-a995-ee32615ace9e です。" + }, + "received": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_MULTIPLE_2531781f-bc9e-45a1-a995-ee32615ace9e です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_MULTIPLE_2531781f-bc9e-45a1-a995-ee32615ace9e です。", + "delivery_id": "5acdfa56-7a2a-4d52-84c2-7ee5966f1fa4", + "digest": "2c74496deab155b53e5b65e1335d4cd2d1e341af00c31081247c63708f5d7975", + "preview": false, + "order": 47, + "session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "turn_id": "01a0e989-bb2a-7580-bb6e-0c985c492b3f", + "hook": null, + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "entry": { + "uuid": null, + "type": "response_item:message:user", + "role": "user" + } + }, + "reply": { + "order": 50, + "session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "turn_id": "01a0e989-bb2a-7580-bb6e-0c985c492b3f", + "text": "- 配送確認の符号:`PEERTABLE_ACCEPT_CODEX_MULTIPLE_2531781f-bc9e-45a1-a995-ee32615ace9e`\n- 送信者:`probe`\n- 宛先:`[\"bell\",\"rui\"]`\n- seq:`4`" + }, + "spool": { + "delivery_id": "5acdfa56-7a2a-4d52-84c2-7ee5966f1fa4", + "state": "submitted", + "queued_submission_id": "01a0e989-b2f5-7c40-94e3-fd1867db1113", + "accepted_at": "2026-09-28T19:41:50.710Z", + "reason": "codex_queue_accepted" + }, + "receipt": { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "codex_queue_accepted", + "receipt_revision": 1, + "queued_submission_id": "01a0e989-b2f5-7c40-94e3-fd1867db1113", + "accepted_at": "2026-09-28T19:41:50.710Z" + } + }, + { + "status": "passed", + "reasons": [], + "label": "all", + "recipient": "bell", + "original": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_ALL_d699f185-0e05-4f1a-b060-d22fcdc393b3 です。" + }, + "received": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_ALL_d699f185-0e05-4f1a-b060-d22fcdc393b3 です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-codex-0594c501-dd42-4b01-bdf6-ed5280afc1f0", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_ALL_d699f185-0e05-4f1a-b060-d22fcdc393b3 です。", + "delivery_id": "0be9d2d6-be47-4946-a59e-551bd54c0ccf", + "digest": "99822f5837a808110166912457083810615f80f2fac3ff29f442a98d5177b548", + "preview": false, + "order": 56, + "session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "turn_id": "01a0e989-e247-7713-b3a7-097cca48d1cf", + "hook": null, + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "entry": { + "uuid": null, + "type": "response_item:message:user", + "role": "user" + } + }, + "reply": { + "order": 59, + "session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "turn_id": "01a0e989-e247-7713-b3a7-097cca48d1cf", + "text": "- 配送確認の符号:`PEERTABLE_ACCEPT_CODEX_ALL_d699f185-0e05-4f1a-b060-d22fcdc393b3`\n- 送信者:`probe`\n- 宛先:`all`\n- seq:`5`" + }, + "spool": { + "delivery_id": "0be9d2d6-be47-4946-a59e-551bd54c0ccf", + "state": "submitted", + "queued_submission_id": "01a0e989-d277-75e1-ab0b-a63e86bec62f", + "accepted_at": "2026-09-28T19:41:58.776Z", + "reason": "codex_queue_accepted" + }, + "receipt": { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "codex_queue_accepted", + "receipt_revision": 1, + "queued_submission_id": "01a0e989-d277-75e1-ab0b-a63e86bec62f", + "accepted_at": "2026-09-28T19:41:58.776Z" + } + } + ], + "transcript_rows": 63, + "turns_seen": [ + "01a0e989-44fe-7131-80ca-8607061db73f", + "01a0e989-bb2a-7580-bb6e-0c985c492b3f", + "01a0e989-e247-7713-b3a7-097cca48d1cf" + ] +} diff --git a/rag/parent-delivery/snapshots/017bf2c/live/linux/claude/cli/audience.json b/rag/parent-delivery/snapshots/017bf2c/live/linux/claude/cli/audience.json new file mode 100644 index 00000000..8aebf346 --- /dev/null +++ b/rag/parent-delivery/snapshots/017bf2c/live/linux/claude/cli/audience.json @@ -0,0 +1,496 @@ +{ + "schema": "peertable.parent-live-case.v1", + "case_id": "linux/claude/cli/audience", + "status": "passed", + "reasons": [], + "native": true, + "os": "linux", + "harness": "claude", + "surface": "cli", + "source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "runtime_digest": "aab616d50a6a0ec7752f492e1f28cfa73f6afb501b31a3d3eae1041584bfc2f2", + "package_version": "0.8.63", + "client_version": "0.8.63", + "package_tarball_sha256": "5bb34d5512b18b4ad9c920c707002b8f04d694f0643eec3766ca97e0da1ffc2e", + "harness_version": "2.1.284 (Claude Code)", + "node_version": "v24.14.1", + "parent_session": "207e0b7b-51c5-4563-b6c1-82b13dfeca9d", + "parent_process": { + "pid": 4011965, + "parent": 4011964, + "started": "0ab63c2c-462b-4839-98b2-057f138353d6:34629518", + "executable": "~/.nvm/versions/node/v24.14.1/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe", + "command": "claude --model claude-sonnet-5-5 " + }, + "endpoint_id": "c0bd450e-c301-473e-9054-c7a9bfe41125", + "room": "parent-acceptance-claude-f7ade8bf-c22d-46fb-95a4-c3bebacdce5e", + "owner_input_required": false, + "isolation": { + "home": "normal", + "room": "local_private_room_server", + "session_args": [], + "folder_trust": "起動dialogで試験dirを信頼(Claudeのproject履歴に残る)" + }, + "controller": { + "files_sha256": { + "README.md": "b388692fe0b584dd88ef73180bb9cebd5bf8e20b22c38a930251c1873c2a4a10", + "aiterm.mjs": "c3fa88a1032455dd70698e567d0e364d4714a6c3afd96cff46d5aad6ab953b16", + "background-harness.mjs": "40614a98000ec761dc38c6ba54a114c744db450d7efa7bc67a1276ab9a5ddca0", + "evidence.mjs": "216a4667764bcd7b7c402e2d81353f5c3cba200f20a303b1b1abb80735280490", + "harness.mjs": "282d2bf7d8aff8493a05eaef9903e2eadda85e3d4ef45a7762242d0f209f3712", + "run.mjs": "1b0aad3d3be8bf2a430c6469559b3d00bd579ed6d8e0bb4eb3582a5e4b4fc1ac", + "runner-provenance.mjs": "ec44651161c6f27bad824c75102a299c0ce6502b8ab6b7f640114f6488e4ad74", + "scenarios-context.mjs": "aa569402743406df39d9ff4d0bdf50fe7dcbd1c4ff3f2dd1858be5c44aa403e5", + "scenarios-fixtures.mjs": "f4ad05cafcede3132c322fee4f768626b97275e051219cc70241a0286a1e0bf8", + "scenarios-lease-run.mjs": "6348a310b1f56a350eb6c38c3bcd6b729bef369da408229a1a837a55f024becc", + "scenarios-native.mjs": "cce9267bc404b41c89e56608888086b3efb4aa1ff89af934f510358dd41b42fe", + "scenarios-surfaces.mjs": "bf5fab85bbdb8d50f4182d0db635779c0ced280eb0fda7ddcb28423da5906ea8", + "scenarios.mjs": "08db776c879b02b8da7b6263511256b192d35a6d78fdaf1164e05328184fca98" + } + }, + "runner": { + "commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "modules_sha256": { + "README.md": "b388692fe0b584dd88ef73180bb9cebd5bf8e20b22c38a930251c1873c2a4a10", + "aiterm.mjs": "c3fa88a1032455dd70698e567d0e364d4714a6c3afd96cff46d5aad6ab953b16", + "background-harness.mjs": "40614a98000ec761dc38c6ba54a114c744db450d7efa7bc67a1276ab9a5ddca0", + "evidence.mjs": "216a4667764bcd7b7c402e2d81353f5c3cba200f20a303b1b1abb80735280490", + "harness.mjs": "282d2bf7d8aff8493a05eaef9903e2eadda85e3d4ef45a7762242d0f209f3712", + "run.mjs": "1b0aad3d3be8bf2a430c6469559b3d00bd579ed6d8e0bb4eb3582a5e4b4fc1ac", + "runner-provenance.mjs": "ec44651161c6f27bad824c75102a299c0ce6502b8ab6b7f640114f6488e4ad74", + "scenarios-context.mjs": "aa569402743406df39d9ff4d0bdf50fe7dcbd1c4ff3f2dd1858be5c44aa403e5", + "scenarios-fixtures.mjs": "f4ad05cafcede3132c322fee4f768626b97275e051219cc70241a0286a1e0bf8", + "scenarios-lease-run.mjs": "6348a310b1f56a350eb6c38c3bcd6b729bef369da408229a1a837a55f024becc", + "scenarios-native.mjs": "cce9267bc404b41c89e56608888086b3efb4aa1ff89af934f510358dd41b42fe", + "scenarios-surfaces.mjs": "bf5fab85bbdb8d50f4182d0db635779c0ced280eb0fda7ddcb28423da5906ea8", + "scenarios.mjs": "08db776c879b02b8da7b6263511256b192d35a6d78fdaf1164e05328184fca98" + }, + "product_source_is_separate": true + }, + "observations": [ + { + "kind": "parent_join", + "parent_session": "207e0b7b-51c5-4563-b6c1-82b13dfeca9d", + "turn_id": "035ad97d-0f8a-42fc-943a-63fcd1359c59", + "tool_use_id": "toolu_01Hj8Pk7wJQ7NrUuKpuDMqSi", + "tool_name": "mcp__peertable_parent__parent_join", + "endpoint_id": "c0bd450e-c301-473e-9054-c7a9bfe41125", + "caller_owner": { + "pid": 4011965, + "parent": 4011964, + "started": "0ab63c2c-462b-4839-98b2-057f138353d6:34629518", + "executable": "~/.nvm/versions/node/v24.14.1/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe", + "command": "claude --model claude-sonnet-5-5 " + }, + "call_generation": "a7c5d9a2-bc08-4a8f-88ec-ce92d4b0178e" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "dm", + "parent_session": "207e0b7b-51c5-4563-b6c1-82b13dfeca9d", + "turn_id": "3258827a-eed5-4f5e-aa87-c10ff129c73a", + "seq": 3, + "delivery_id": "5675de08-24f3-494e-9980-1b4221188815", + "entry": { + "uuid": "b3f0d3e7-d565-40c1-b028-a262f6fd0466", + "type": "user", + "role": null + }, + "native_hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "native_queue": [ + "enqueue" + ], + "native_ack_id": "b3f0d3e7-d565-40c1-b028-a262f6fd0466" + }, + { + "kind": "parent_reply_after_delivery", + "label": "dm", + "parent_session": "207e0b7b-51c5-4563-b6c1-82b13dfeca9d", + "turn_id": "3258827a-eed5-4f5e-aa87-c10ff129c73a", + "seq": 3, + "entry_uuid": "e7a779a6-3161-47fa-a906-c6ac9bca381f" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "multiple", + "parent_session": "207e0b7b-51c5-4563-b6c1-82b13dfeca9d", + "turn_id": "3a7ea80d-4728-40b0-9999-ed35f58d5fe4", + "seq": 4, + "delivery_id": "adc3925e-b1cf-489d-89dc-07423d07f8ca", + "entry": { + "uuid": "c5cbd65d-a0e9-4eeb-8bb0-8e31946d1e35", + "type": "user", + "role": null + }, + "native_hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "native_queue": [ + "enqueue" + ], + "native_ack_id": "c5cbd65d-a0e9-4eeb-8bb0-8e31946d1e35" + }, + { + "kind": "parent_reply_after_delivery", + "label": "multiple", + "parent_session": "207e0b7b-51c5-4563-b6c1-82b13dfeca9d", + "turn_id": "3a7ea80d-4728-40b0-9999-ed35f58d5fe4", + "seq": 4, + "entry_uuid": "9dece50f-292d-467f-a4f7-36afc6bf11b8" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "all", + "parent_session": "207e0b7b-51c5-4563-b6c1-82b13dfeca9d", + "turn_id": "8408518c-e972-40a9-955e-0bc0fe1c042e", + "seq": 5, + "delivery_id": "2de14aeb-0b52-4ba4-b611-5974b59ba005", + "entry": { + "uuid": "b07f02c5-19a0-4e3f-a83e-83e251c909bf", + "type": "user", + "role": null + }, + "native_hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "native_queue": [ + "enqueue" + ], + "native_ack_id": "b07f02c5-19a0-4e3f-a83e-83e251c909bf" + }, + { + "kind": "parent_reply_after_delivery", + "label": "all", + "parent_session": "207e0b7b-51c5-4563-b6c1-82b13dfeca9d", + "turn_id": "8408518c-e972-40a9-955e-0bc0fe1c042e", + "seq": 5, + "entry_uuid": "a08c8a99-808e-4bf9-b912-1ed71cf91cd5" + } + ], + "receipts": [ + { + "seq": 3, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + } + ], + "body_checks": [ + { + "label": "dm", + "original": { + "room": "parent-acceptance-claude-f7ade8bf-c22d-46fb-95a4-c3bebacdce5e", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_DM_0ac4d120-9b46-44fa-8729-ff1e92a1eb9a です。" + }, + "received": { + "room": "parent-acceptance-claude-f7ade8bf-c22d-46fb-95a4-c3bebacdce5e", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_DM_0ac4d120-9b46-44fa-8729-ff1e92a1eb9a です。" + } + }, + { + "label": "multiple", + "original": { + "room": "parent-acceptance-claude-f7ade8bf-c22d-46fb-95a4-c3bebacdce5e", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_b13a7e2a-4e86-4194-9b06-7000c127eb64 です。" + }, + "received": { + "room": "parent-acceptance-claude-f7ade8bf-c22d-46fb-95a4-c3bebacdce5e", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_b13a7e2a-4e86-4194-9b06-7000c127eb64 です。" + } + }, + { + "label": "all", + "original": { + "room": "parent-acceptance-claude-f7ade8bf-c22d-46fb-95a4-c3bebacdce5e", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_ALL_7dc77a89-d3e7-44cb-a333-f81c9f100c49 です。" + }, + "received": { + "room": "parent-acceptance-claude-f7ade8bf-c22d-46fb-95a4-c3bebacdce5e", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_ALL_7dc77a89-d3e7-44cb-a333-f81c9f100c49 です。" + } + } + ], + "checks": [ + { + "status": "passed", + "reasons": [], + "label": "dm", + "recipient": "bell", + "original": { + "room": "parent-acceptance-claude-f7ade8bf-c22d-46fb-95a4-c3bebacdce5e", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_DM_0ac4d120-9b46-44fa-8729-ff1e92a1eb9a です。" + }, + "received": { + "room": "parent-acceptance-claude-f7ade8bf-c22d-46fb-95a4-c3bebacdce5e", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_DM_0ac4d120-9b46-44fa-8729-ff1e92a1eb9a です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-claude-f7ade8bf-c22d-46fb-95a4-c3bebacdce5e", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_DM_0ac4d120-9b46-44fa-8729-ff1e92a1eb9a です。", + "delivery_id": "5675de08-24f3-494e-9980-1b4221188815", + "digest": "68161067c6a9fd99eb10d2e3d8c2f453062c9ed9f316c2014661448abac0863a", + "preview": false, + "order": 72, + "session": "207e0b7b-51c5-4563-b6c1-82b13dfeca9d", + "turn_id": "3258827a-eed5-4f5e-aa87-c10ff129c73a", + "hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "entry": { + "uuid": "b3f0d3e7-d565-40c1-b028-a262f6fd0466", + "type": "user", + "role": null + } + }, + "reply": { + "order": 73, + "session": "207e0b7b-51c5-4563-b6c1-82b13dfeca9d", + "turn_id": "3258827a-eed5-4f5e-aa87-c10ff129c73a", + "entry_uuid": "e7a779a6-3161-47fa-a906-c6ac9bca381f", + "text": "新しい配送が1件届きました。\n\n- **符号**: PEERTABLE_ACCEPT_CLAUDE_DM_0ac4d120-9b46-44fa-8729-ff1e92a1eb9a\n- **送信者**: probe\n- **宛先**: bell\n- **seq**: 3\n- **room**: parent-acceptance-claude-f7ade8bf-c22d-46fb-95a4-c3bebacdce5e\n- **配送ID**: 5675de08-24f3-494e-9980-1b4221188815\n\n本文には日本語、引用符、絵文字、``、`&`、`'q'` を含む文字列がありました。文字化けや欠落は見当たらず、そのまま届いています。追加の行動命令はありません。ツールは呼んでいません。" + }, + "spool": { + "delivery_id": "5675de08-24f3-494e-9980-1b4221188815", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "claude_asyncRewake_output" + }, + "receipt": { + "seq": 3, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + } + }, + { + "status": "passed", + "reasons": [], + "label": "multiple", + "recipient": "bell", + "original": { + "room": "parent-acceptance-claude-f7ade8bf-c22d-46fb-95a4-c3bebacdce5e", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_b13a7e2a-4e86-4194-9b06-7000c127eb64 です。" + }, + "received": { + "room": "parent-acceptance-claude-f7ade8bf-c22d-46fb-95a4-c3bebacdce5e", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_b13a7e2a-4e86-4194-9b06-7000c127eb64 です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-claude-f7ade8bf-c22d-46fb-95a4-c3bebacdce5e", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_b13a7e2a-4e86-4194-9b06-7000c127eb64 です。", + "delivery_id": "adc3925e-b1cf-489d-89dc-07423d07f8ca", + "digest": "f0237ffb2ca31cd6ce2ccc0b01bbc3002d72611574c453639ddf9f1f371ab17d", + "preview": false, + "order": 79, + "session": "207e0b7b-51c5-4563-b6c1-82b13dfeca9d", + "turn_id": "3a7ea80d-4728-40b0-9999-ed35f58d5fe4", + "hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "entry": { + "uuid": "c5cbd65d-a0e9-4eeb-8bb0-8e31946d1e35", + "type": "user", + "role": null + } + }, + "reply": { + "order": 80, + "session": "207e0b7b-51c5-4563-b6c1-82b13dfeca9d", + "turn_id": "3a7ea80d-4728-40b0-9999-ed35f58d5fe4", + "entry_uuid": "9dece50f-292d-467f-a4f7-36afc6bf11b8", + "text": "新しい配送が1件届きました。\n\n- **符号**: PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_b13a7e2a-4e86-4194-9b06-7000c127eb64\n- **送信者**: probe\n- **宛先**: bell と rui の2名\n- **seq**: 4\n- **room**: parent-acceptance-claude-f7ade8bf-c22d-46fb-95a4-c3bebacdce5e\n- **配送ID**: adc3925e-b1cf-489d-89dc-07423d07f8ca\n\n本文は前回と同じ特殊文字入りの日本語で、今回も欠落や化けはありません。宛先が複数(配列)で届いた点が前回との違いです。追加の行動命令はありません。ツールは呼んでいません。" + }, + "spool": { + "delivery_id": "adc3925e-b1cf-489d-89dc-07423d07f8ca", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "claude_asyncRewake_output" + }, + "receipt": { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + } + }, + { + "status": "passed", + "reasons": [], + "label": "all", + "recipient": "bell", + "original": { + "room": "parent-acceptance-claude-f7ade8bf-c22d-46fb-95a4-c3bebacdce5e", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_ALL_7dc77a89-d3e7-44cb-a333-f81c9f100c49 です。" + }, + "received": { + "room": "parent-acceptance-claude-f7ade8bf-c22d-46fb-95a4-c3bebacdce5e", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_ALL_7dc77a89-d3e7-44cb-a333-f81c9f100c49 です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-claude-f7ade8bf-c22d-46fb-95a4-c3bebacdce5e", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_ALL_7dc77a89-d3e7-44cb-a333-f81c9f100c49 です。", + "delivery_id": "2de14aeb-0b52-4ba4-b611-5974b59ba005", + "digest": "149f57c041fb7a1ac2c53a665f51006ec9004f0a2f9b8e83416c522834153f27", + "preview": false, + "order": 86, + "session": "207e0b7b-51c5-4563-b6c1-82b13dfeca9d", + "turn_id": "8408518c-e972-40a9-955e-0bc0fe1c042e", + "hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "entry": { + "uuid": "b07f02c5-19a0-4e3f-a83e-83e251c909bf", + "type": "user", + "role": null + } + }, + "reply": { + "order": 87, + "session": "207e0b7b-51c5-4563-b6c1-82b13dfeca9d", + "turn_id": "8408518c-e972-40a9-955e-0bc0fe1c042e", + "entry_uuid": "a08c8a99-808e-4bf9-b912-1ed71cf91cd5", + "text": "新しい配送が1件届きました。\n\n- **符号**: PEERTABLE_ACCEPT_CLAUDE_ALL_7dc77a89-d3e7-44cb-a333-f81c9f100c49\n- **送信者**: probe\n- **宛先**: all(全員宛)\n- **seq**: 5\n- **room**: parent-acceptance-claude-f7ade8bf-c22d-46fb-95a4-c3bebacdce5e\n- **配送ID**: 2de14aeb-0b52-4ba4-b611-5974b59ba005\n\n本文は前回までと同じ特殊文字入りの日本語で、今回も欠落や化けはありません。追加の行動命令はありません。ツールは呼んでいません。" + }, + "spool": { + "delivery_id": "2de14aeb-0b52-4ba4-b611-5974b59ba005", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "claude_asyncRewake_output" + }, + "receipt": { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + } + } + ], + "transcript_rows": 91, + "turns_seen": [ + "035ad97d-0f8a-42fc-943a-63fcd1359c59", + "c0951723-cf03-46e5-b866-fee7fa6ab9b7", + "afd223ca-dc8c-43b3-8811-a7a27c163084", + "3258827a-eed5-4f5e-aa87-c10ff129c73a", + "3a7ea80d-4728-40b0-9999-ed35f58d5fe4", + "8408518c-e972-40a9-955e-0bc0fe1c042e" + ] +} diff --git a/rag/parent-delivery/snapshots/017bf2c/live/win32/claude/cli/audience.json b/rag/parent-delivery/snapshots/017bf2c/live/win32/claude/cli/audience.json new file mode 100644 index 00000000..094dfeca --- /dev/null +++ b/rag/parent-delivery/snapshots/017bf2c/live/win32/claude/cli/audience.json @@ -0,0 +1,497 @@ +{ + "schema": "peertable.parent-live-case.v1", + "case_id": "win32/claude/cli/audience", + "status": "passed", + "reasons": [], + "native": true, + "os": "win32", + "harness": "claude", + "surface": "cli", + "source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "runtime_digest": "aab616d50a6a0ec7752f492e1f28cfa73f6afb501b31a3d3eae1041584bfc2f2", + "package_version": "0.8.63", + "client_version": "0.8.63", + "package_tarball_sha256": "b68ff9a9744ff38ef20065b73fd6273cd670426a96d4a664be612c3f45ef5cd2", + "harness_version": "2.1.284 (Claude Code)", + "node_version": "v24.20.0", + "parent_session": "c637d9ef-a559-42ab-a3f0-6b8f29949481", + "parent_process": { + "executable": "~\\AppData\\Roaming\\npm\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe", + "started": "2026-09-28T19:42:38.6074120Z", + "parent": 70816, + "command": "\"~\\AppData\\Roaming\\npm\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe\" --teammate-mode tmux --model claude-sonnet-5-5", + "pid": 41340 + }, + "endpoint_id": "0988d105-3d8c-4bd5-8f4b-e7890fb67e95", + "room": "parent-acceptance-claude-d43b964e-7e35-442e-9480-1b2cae0a3cde", + "owner_input_required": false, + "isolation": { + "home": "normal", + "room": "local_private_room_server", + "session_args": [], + "folder_trust": "起動dialogで試験dirを信頼(Claudeのproject履歴に残る)" + }, + "controller": { + "files_sha256": { + "README.md": "b388692fe0b584dd88ef73180bb9cebd5bf8e20b22c38a930251c1873c2a4a10", + "aiterm.mjs": "c3fa88a1032455dd70698e567d0e364d4714a6c3afd96cff46d5aad6ab953b16", + "background-harness.mjs": "40614a98000ec761dc38c6ba54a114c744db450d7efa7bc67a1276ab9a5ddca0", + "evidence.mjs": "216a4667764bcd7b7c402e2d81353f5c3cba200f20a303b1b1abb80735280490", + "harness.mjs": "282d2bf7d8aff8493a05eaef9903e2eadda85e3d4ef45a7762242d0f209f3712", + "run.mjs": "1b0aad3d3be8bf2a430c6469559b3d00bd579ed6d8e0bb4eb3582a5e4b4fc1ac", + "runner-provenance.mjs": "ec44651161c6f27bad824c75102a299c0ce6502b8ab6b7f640114f6488e4ad74", + "scenarios-context.mjs": "aa569402743406df39d9ff4d0bdf50fe7dcbd1c4ff3f2dd1858be5c44aa403e5", + "scenarios-fixtures.mjs": "f4ad05cafcede3132c322fee4f768626b97275e051219cc70241a0286a1e0bf8", + "scenarios-lease-run.mjs": "6348a310b1f56a350eb6c38c3bcd6b729bef369da408229a1a837a55f024becc", + "scenarios-native.mjs": "cce9267bc404b41c89e56608888086b3efb4aa1ff89af934f510358dd41b42fe", + "scenarios-surfaces.mjs": "bf5fab85bbdb8d50f4182d0db635779c0ced280eb0fda7ddcb28423da5906ea8", + "scenarios.mjs": "08db776c879b02b8da7b6263511256b192d35a6d78fdaf1164e05328184fca98" + } + }, + "runner": { + "commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "modules_sha256": { + "README.md": "b388692fe0b584dd88ef73180bb9cebd5bf8e20b22c38a930251c1873c2a4a10", + "aiterm.mjs": "c3fa88a1032455dd70698e567d0e364d4714a6c3afd96cff46d5aad6ab953b16", + "background-harness.mjs": "40614a98000ec761dc38c6ba54a114c744db450d7efa7bc67a1276ab9a5ddca0", + "evidence.mjs": "216a4667764bcd7b7c402e2d81353f5c3cba200f20a303b1b1abb80735280490", + "harness.mjs": "282d2bf7d8aff8493a05eaef9903e2eadda85e3d4ef45a7762242d0f209f3712", + "run.mjs": "1b0aad3d3be8bf2a430c6469559b3d00bd579ed6d8e0bb4eb3582a5e4b4fc1ac", + "runner-provenance.mjs": "ec44651161c6f27bad824c75102a299c0ce6502b8ab6b7f640114f6488e4ad74", + "scenarios-context.mjs": "aa569402743406df39d9ff4d0bdf50fe7dcbd1c4ff3f2dd1858be5c44aa403e5", + "scenarios-fixtures.mjs": "f4ad05cafcede3132c322fee4f768626b97275e051219cc70241a0286a1e0bf8", + "scenarios-lease-run.mjs": "6348a310b1f56a350eb6c38c3bcd6b729bef369da408229a1a837a55f024becc", + "scenarios-native.mjs": "cce9267bc404b41c89e56608888086b3efb4aa1ff89af934f510358dd41b42fe", + "scenarios-surfaces.mjs": "bf5fab85bbdb8d50f4182d0db635779c0ced280eb0fda7ddcb28423da5906ea8", + "scenarios.mjs": "08db776c879b02b8da7b6263511256b192d35a6d78fdaf1164e05328184fca98" + }, + "product_source_is_separate": true + }, + "observations": [ + { + "kind": "parent_join", + "parent_session": "c637d9ef-a559-42ab-a3f0-6b8f29949481", + "turn_id": "75a71683-dd61-496a-adde-66ce79a4194a", + "tool_use_id": "toolu_017kDAXquoxSTySirQeEQxW1", + "tool_name": "mcp__peertable_parent__parent_join", + "endpoint_id": "0988d105-3d8c-4bd5-8f4b-e7890fb67e95", + "caller_owner": { + "executable": "~\\AppData\\Roaming\\npm\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe", + "started": "2026-09-28T19:42:38.6074120Z", + "parent": 70816, + "command": "\"~\\AppData\\Roaming\\npm\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe\" --teammate-mode tmux --model claude-sonnet-5-5", + "pid": 41340 + }, + "call_generation": "b58b3c8c-3b34-47d5-b0d3-dba0bbd53550" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "dm", + "parent_session": "c637d9ef-a559-42ab-a3f0-6b8f29949481", + "turn_id": "2db14ae3-d6cd-4085-8ea6-2b3fe5ec30b7", + "seq": 3, + "delivery_id": "1d7105f9-982d-4976-b955-5158d833759e", + "entry": { + "uuid": "a00c81c2-4678-4e2b-8df7-634eb3bbb92c", + "type": "user", + "role": null + }, + "native_hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "native_queue": [ + "enqueue" + ], + "native_ack_id": "a00c81c2-4678-4e2b-8df7-634eb3bbb92c" + }, + { + "kind": "parent_reply_after_delivery", + "label": "dm", + "parent_session": "c637d9ef-a559-42ab-a3f0-6b8f29949481", + "turn_id": "2db14ae3-d6cd-4085-8ea6-2b3fe5ec30b7", + "seq": 3, + "entry_uuid": "af40f38e-e5dc-46ff-bb28-58d762cd6cc6" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "multiple", + "parent_session": "c637d9ef-a559-42ab-a3f0-6b8f29949481", + "turn_id": "95545bcd-fa86-4f02-b3a9-c706d2cdf9a3", + "seq": 4, + "delivery_id": "7a6b3ae5-a4cc-4fc6-9ec8-f3676a645cea", + "entry": { + "uuid": "1a124002-be41-4e3c-9b76-c97346bd8634", + "type": "user", + "role": null + }, + "native_hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "native_queue": [ + "enqueue" + ], + "native_ack_id": "1a124002-be41-4e3c-9b76-c97346bd8634" + }, + { + "kind": "parent_reply_after_delivery", + "label": "multiple", + "parent_session": "c637d9ef-a559-42ab-a3f0-6b8f29949481", + "turn_id": "95545bcd-fa86-4f02-b3a9-c706d2cdf9a3", + "seq": 4, + "entry_uuid": "1c8480cb-c11e-4b61-9984-06a7dfcd0fc4" + }, + { + "kind": "delivery_in_parent_conversation", + "label": "all", + "parent_session": "c637d9ef-a559-42ab-a3f0-6b8f29949481", + "turn_id": "68622e5c-f437-4727-a24d-54750785d5c8", + "seq": 5, + "delivery_id": "d6e4ad4b-8ee9-4a2d-86f0-cc5949a11ad2", + "entry": { + "uuid": "410fbac1-5086-48fb-85a3-b030c45c7342", + "type": "user", + "role": null + }, + "native_hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "native_queue": [ + "enqueue" + ], + "native_ack_id": "410fbac1-5086-48fb-85a3-b030c45c7342" + }, + { + "kind": "parent_reply_after_delivery", + "label": "all", + "parent_session": "c637d9ef-a559-42ab-a3f0-6b8f29949481", + "turn_id": "68622e5c-f437-4727-a24d-54750785d5c8", + "seq": 5, + "entry_uuid": "fd1c2d19-a94c-47fe-b7c7-dfa8158408a3" + } + ], + "receipts": [ + { + "seq": 3, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + }, + { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + } + ], + "body_checks": [ + { + "label": "dm", + "original": { + "room": "parent-acceptance-claude-d43b964e-7e35-442e-9480-1b2cae0a3cde", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_DM_e7b44614-9988-4de6-b480-e99d77dcde06 です。" + }, + "received": { + "room": "parent-acceptance-claude-d43b964e-7e35-442e-9480-1b2cae0a3cde", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_DM_e7b44614-9988-4de6-b480-e99d77dcde06 です。" + } + }, + { + "label": "multiple", + "original": { + "room": "parent-acceptance-claude-d43b964e-7e35-442e-9480-1b2cae0a3cde", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_eaa4646f-00e3-4f49-b66c-8f221ab79e78 です。" + }, + "received": { + "room": "parent-acceptance-claude-d43b964e-7e35-442e-9480-1b2cae0a3cde", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_eaa4646f-00e3-4f49-b66c-8f221ab79e78 です。" + } + }, + { + "label": "all", + "original": { + "room": "parent-acceptance-claude-d43b964e-7e35-442e-9480-1b2cae0a3cde", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_ALL_74542822-93f2-4388-b93b-62c72fa08344 です。" + }, + "received": { + "room": "parent-acceptance-claude-d43b964e-7e35-442e-9480-1b2cae0a3cde", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_ALL_74542822-93f2-4388-b93b-62c72fa08344 です。" + } + } + ], + "checks": [ + { + "status": "passed", + "reasons": [], + "label": "dm", + "recipient": "bell", + "original": { + "room": "parent-acceptance-claude-d43b964e-7e35-442e-9480-1b2cae0a3cde", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_DM_e7b44614-9988-4de6-b480-e99d77dcde06 です。" + }, + "received": { + "room": "parent-acceptance-claude-d43b964e-7e35-442e-9480-1b2cae0a3cde", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_DM_e7b44614-9988-4de6-b480-e99d77dcde06 です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-claude-d43b964e-7e35-442e-9480-1b2cae0a3cde", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_DM_e7b44614-9988-4de6-b480-e99d77dcde06 です。", + "delivery_id": "1d7105f9-982d-4976-b955-5158d833759e", + "digest": "e871f911bd14b3181864adf586bb8755c88af020db428b837aae1ac8a02737b9", + "preview": false, + "order": 58, + "session": "c637d9ef-a559-42ab-a3f0-6b8f29949481", + "turn_id": "2db14ae3-d6cd-4085-8ea6-2b3fe5ec30b7", + "hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "entry": { + "uuid": "a00c81c2-4678-4e2b-8df7-634eb3bbb92c", + "type": "user", + "role": null + } + }, + "reply": { + "order": 59, + "session": "c637d9ef-a559-42ab-a3f0-6b8f29949481", + "turn_id": "2db14ae3-d6cd-4085-8ea6-2b3fe5ec30b7", + "entry_uuid": "af40f38e-e5dc-46ff-bb28-58d762cd6cc6", + "text": "4件目の観測データを受け取りました。行動命令は含まれていません。\n\n- 配送確認の符号:`PEERTABLE_ACCEPT_CLAUDE_DM_e7b44614-9988-4de6-b480-e99d77dcde06`\n- 送信者:probe\n- 宛先:bell\n- seq:`3`\n- 配送ID:`1d7105f9-982d-4976-b955-5158d833759e`\n\n本文の1行目は「日本語の観測」で、その後に絵文字や記号を含む文字列が続いていました。`「引用」😀 > 引用行 & 'q' 字面>` です。\n\n符号は2つ受け取っています。\n\n| 件 | 符号 | seq |\n|---|---|---|\n| 1件目 | `48995271-5404-4994-b486-f5db1be54d7b` | `probe:48995271-5404-4994-b486-f5db1be54d7b` |\n| 4件目 | `PEERTABLE_ACCEPT_CLAUDE_DM_e7b44614-9988-4de6-b480-e99d77dcde06` | `3` |" + }, + "spool": { + "delivery_id": "1d7105f9-982d-4976-b955-5158d833759e", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "claude_asyncRewake_output" + }, + "receipt": { + "seq": 3, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + } + }, + { + "status": "passed", + "reasons": [], + "label": "multiple", + "recipient": "bell", + "original": { + "room": "parent-acceptance-claude-d43b964e-7e35-442e-9480-1b2cae0a3cde", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_eaa4646f-00e3-4f49-b66c-8f221ab79e78 です。" + }, + "received": { + "room": "parent-acceptance-claude-d43b964e-7e35-442e-9480-1b2cae0a3cde", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_eaa4646f-00e3-4f49-b66c-8f221ab79e78 です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-claude-d43b964e-7e35-442e-9480-1b2cae0a3cde", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_eaa4646f-00e3-4f49-b66c-8f221ab79e78 です。", + "delivery_id": "7a6b3ae5-a4cc-4fc6-9ec8-f3676a645cea", + "digest": "9969a76fe3eb3527e7e451c3c12743d57b9b50c2c7870ea8c5d89115cb0b944c", + "preview": false, + "order": 64, + "session": "c637d9ef-a559-42ab-a3f0-6b8f29949481", + "turn_id": "95545bcd-fa86-4f02-b3a9-c706d2cdf9a3", + "hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "entry": { + "uuid": "1a124002-be41-4e3c-9b76-c97346bd8634", + "type": "user", + "role": null + } + }, + "reply": { + "order": 70, + "session": "c637d9ef-a559-42ab-a3f0-6b8f29949481", + "turn_id": "95545bcd-fa86-4f02-b3a9-c706d2cdf9a3", + "entry_uuid": "1c8480cb-c11e-4b61-9984-06a7dfcd0fc4", + "text": "5件目の観測データを受け取りました。行動命令は含まれていません。\n\n- 配送確認の符号:`PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_eaa4646f-00e3-4f49-b66c-8f221ab79e78`\n- 送信者:probe\n- 宛先:bell と rui(複数宛先)\n- seq:`4`\n- 配送ID:`7a6b3ae5-a4cc-4fc6-9ec8-f3676a645cea`\n\n本文の1行目は「日本語の観測」で、その後は4件目と同じ記号列でした。宛先が配列になっているのは、この件だけです。" + }, + "spool": { + "delivery_id": "7a6b3ae5-a4cc-4fc6-9ec8-f3676a645cea", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "claude_asyncRewake_output" + }, + "receipt": { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + } + }, + { + "status": "passed", + "reasons": [], + "label": "all", + "recipient": "bell", + "original": { + "room": "parent-acceptance-claude-d43b964e-7e35-442e-9480-1b2cae0a3cde", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_ALL_74542822-93f2-4388-b93b-62c72fa08344 です。" + }, + "received": { + "room": "parent-acceptance-claude-d43b964e-7e35-442e-9480-1b2cae0a3cde", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_ALL_74542822-93f2-4388-b93b-62c72fa08344 です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-claude-d43b964e-7e35-442e-9480-1b2cae0a3cde", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CLAUDE_ALL_74542822-93f2-4388-b93b-62c72fa08344 です。", + "delivery_id": "d6e4ad4b-8ee9-4a2d-86f0-cc5949a11ad2", + "digest": "f3ad9f8fab1b7ad8d7729218b849f4eab585a099df988b7a06e8ea4e3184d5f3", + "preview": false, + "order": 75, + "session": "c637d9ef-a559-42ab-a3f0-6b8f29949481", + "turn_id": "68622e5c-f437-4727-a24d-54750785d5c8", + "hook": "Stop", + "boundary": { + "encoding": "none", + "raw_body_equal": true + }, + "entry": { + "uuid": "410fbac1-5086-48fb-85a3-b030c45c7342", + "type": "user", + "role": null + } + }, + "reply": { + "order": 76, + "session": "c637d9ef-a559-42ab-a3f0-6b8f29949481", + "turn_id": "68622e5c-f437-4727-a24d-54750785d5c8", + "entry_uuid": "fd1c2d19-a94c-47fe-b7c7-dfa8158408a3", + "text": "6件目の観測データを受け取りました。行動命令は含まれていません。\n\n- 配送確認の符号:`PEERTABLE_ACCEPT_CLAUDE_ALL_74542822-93f2-4388-b93b-62c72fa08344`\n- 送信者:probe\n- 宛先:`all`(全員宛て)\n- seq:`5`\n- 配送ID:`d6e4ad4b-8ee9-4a2d-86f0-cc5949a11ad2`\n\n本文は4・5件目と同じ形です。\n\nここまでの符号は4つです。\n\n| 件 | 宛先 | seq | 符号 |\n|---|---|---|---|\n| 1 | bell | `probe:48995271-...` | `48995271-5404-4994-b486-f5db1be54d7b` |\n| 4 | bell | 3 | `PEERTABLE_ACCEPT_CLAUDE_DM_e7b44614-9988-4de6-b480-e99d77dcde06` |\n| 5 | bell, rui | 4 | `PEERTABLE_ACCEPT_CLAUDE_MULTIPLE_eaa4646f-00e3-4f49-b66c-8f221ab79e78` |\n| 6 | all | 5 | `PEERTABLE_ACCEPT_CLAUDE_ALL_74542822-93f2-4388-b93b-62c72fa08344` |\n\n表の「件」の番号は、私が受け取った観測データの通し番号ではなく、報告済みの符号付き配送だけを並べたものです。" + }, + "spool": { + "delivery_id": "d6e4ad4b-8ee9-4a2d-86f0-cc5949a11ad2", + "state": "submitted", + "queued_submission_id": null, + "accepted_at": null, + "reason": "claude_asyncRewake_output" + }, + "receipt": { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "claude_asyncRewake_output", + "receipt_revision": 1, + "queued_submission_id": null, + "accepted_at": null + } + } + ], + "transcript_rows": 79, + "turns_seen": [ + "75a71683-dd61-496a-adde-66ce79a4194a", + "783c96b7-1ed9-4ca3-86ef-72e626ab9f73", + "130cfd80-5e6a-4978-8f65-8aa5bdae4eb2", + "cf195a1a-878c-4fd5-bf0e-9f9ea66a075f", + "2db14ae3-d6cd-4085-8ea6-2b3fe5ec30b7", + "95545bcd-fa86-4f02-b3a9-c706d2cdf9a3", + "68622e5c-f437-4727-a24d-54750785d5c8" + ] +} diff --git a/rag/parent-delivery/snapshots/017bf2c/live/win32/codex/cli/audience.json b/rag/parent-delivery/snapshots/017bf2c/live/win32/codex/cli/audience.json new file mode 100644 index 00000000..92d6cbcc --- /dev/null +++ b/rag/parent-delivery/snapshots/017bf2c/live/win32/codex/cli/audience.json @@ -0,0 +1,485 @@ +{ + "schema": "peertable.parent-live-case.v1", + "case_id": "win32/codex/cli/audience", + "status": "passed", + "reasons": [], + "native": true, + "os": "win32", + "harness": "codex", + "surface": "cli", + "source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "runtime_digest": "aab616d50a6a0ec7752f492e1f28cfa73f6afb501b31a3d3eae1041584bfc2f2", + "package_version": "0.8.63", + "client_version": "0.8.63", + "package_tarball_sha256": "b68ff9a9744ff38ef20065b73fd6273cd670426a96d4a664be612c3f45ef5cd2", + "harness_version": "codex-cli 0.158.0", + "node_version": "v24.20.0", + "parent_session": "01a0e98c-6399-7003-b3f7-3cfcd5c7a295", + "parent_process": { + "started": "2026-09-28T19:44:45.4113440Z", + "pid": 67192, + "executable": "~\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\node_modules\\@openai\\codex-win32-x64\\vendor\\x86_64-pc-windows-msvc\\bin\\codex.exe", + "parent": 40120, + "command": "~\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\node_modules\\@openai\\codex-win32-x64\\vendor\\x86_64-pc-windows-msvc\\bin\\codex.exe -c \"mcp_servers.peertable_parent.env_vars=[\\\"PEERTABLE_TOKEN_SOURCE_FILE\\\"]\" -c check_for_update_on_startup=false -m gpt-6-sol" + }, + "endpoint_id": "7be09068-41b3-437d-b0a1-05019f70874d", + "room": "parent-acceptance-codex-adc9714a-8e25-4b65-bd03-222232fb692f", + "owner_input_required": false, + "isolation": { + "home": "normal", + "room": "local_private_room_server", + "session_args": [ + "-c mcp_servers.peertable_parent.env_vars=[\"PEERTABLE_TOKEN_SOURCE_FILE\"]", + "-c check_for_update_on_startup=false" + ], + "folder_trust": "起動dialogで試験dirを信頼し、終了後に公式config/batchWriteでその projects entry だけを削除" + }, + "controller": { + "files_sha256": { + "README.md": "b388692fe0b584dd88ef73180bb9cebd5bf8e20b22c38a930251c1873c2a4a10", + "aiterm.mjs": "c3fa88a1032455dd70698e567d0e364d4714a6c3afd96cff46d5aad6ab953b16", + "background-harness.mjs": "40614a98000ec761dc38c6ba54a114c744db450d7efa7bc67a1276ab9a5ddca0", + "evidence.mjs": "216a4667764bcd7b7c402e2d81353f5c3cba200f20a303b1b1abb80735280490", + "harness.mjs": "282d2bf7d8aff8493a05eaef9903e2eadda85e3d4ef45a7762242d0f209f3712", + "run.mjs": "1b0aad3d3be8bf2a430c6469559b3d00bd579ed6d8e0bb4eb3582a5e4b4fc1ac", + "runner-provenance.mjs": "ec44651161c6f27bad824c75102a299c0ce6502b8ab6b7f640114f6488e4ad74", + "scenarios-context.mjs": "aa569402743406df39d9ff4d0bdf50fe7dcbd1c4ff3f2dd1858be5c44aa403e5", + "scenarios-fixtures.mjs": "f4ad05cafcede3132c322fee4f768626b97275e051219cc70241a0286a1e0bf8", + "scenarios-lease-run.mjs": "6348a310b1f56a350eb6c38c3bcd6b729bef369da408229a1a837a55f024becc", + "scenarios-native.mjs": "cce9267bc404b41c89e56608888086b3efb4aa1ff89af934f510358dd41b42fe", + "scenarios-surfaces.mjs": "bf5fab85bbdb8d50f4182d0db635779c0ced280eb0fda7ddcb28423da5906ea8", + "scenarios.mjs": "08db776c879b02b8da7b6263511256b192d35a6d78fdaf1164e05328184fca98" + } + }, + "runner": { + "commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "modules_sha256": { + "README.md": "b388692fe0b584dd88ef73180bb9cebd5bf8e20b22c38a930251c1873c2a4a10", + "aiterm.mjs": "c3fa88a1032455dd70698e567d0e364d4714a6c3afd96cff46d5aad6ab953b16", + "background-harness.mjs": "40614a98000ec761dc38c6ba54a114c744db450d7efa7bc67a1276ab9a5ddca0", + "evidence.mjs": "216a4667764bcd7b7c402e2d81353f5c3cba200f20a303b1b1abb80735280490", + "harness.mjs": "282d2bf7d8aff8493a05eaef9903e2eadda85e3d4ef45a7762242d0f209f3712", + "run.mjs": "1b0aad3d3be8bf2a430c6469559b3d00bd579ed6d8e0bb4eb3582a5e4b4fc1ac", + "runner-provenance.mjs": "ec44651161c6f27bad824c75102a299c0ce6502b8ab6b7f640114f6488e4ad74", + "scenarios-context.mjs": "aa569402743406df39d9ff4d0bdf50fe7dcbd1c4ff3f2dd1858be5c44aa403e5", + "scenarios-fixtures.mjs": "f4ad05cafcede3132c322fee4f768626b97275e051219cc70241a0286a1e0bf8", + "scenarios-lease-run.mjs": "6348a310b1f56a350eb6c38c3bcd6b729bef369da408229a1a837a55f024becc", + "scenarios-native.mjs": "cce9267bc404b41c89e56608888086b3efb4aa1ff89af934f510358dd41b42fe", + "scenarios-surfaces.mjs": "bf5fab85bbdb8d50f4182d0db635779c0ced280eb0fda7ddcb28423da5906ea8", + "scenarios.mjs": "08db776c879b02b8da7b6263511256b192d35a6d78fdaf1164e05328184fca98" + }, + "product_source_is_separate": true + }, + "observations": [ + { + "kind": "parent_join", + "parent_session": "01a0e98c-6399-7003-b3f7-3cfcd5c7a295", + "turn_id": "01a0e98c-8710-7402-b313-45c3b4ff9508", + "tool_use_id": "2", + "tool_name": "output", + "endpoint_id": "7be09068-41b3-437d-b0a1-05019f70874d", + "caller_owner": { + "started": "2026-09-28T19:44:45.4113440Z", + "pid": 67192, + "executable": "~\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\node_modules\\@openai\\codex-win32-x64\\vendor\\x86_64-pc-windows-msvc\\bin\\codex.exe", + "parent": 40120, + "command": "~\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\node_modules\\@openai\\codex-win32-x64\\vendor\\x86_64-pc-windows-msvc\\bin\\codex.exe -c \"mcp_servers.peertable_parent.env_vars=[\\\"PEERTABLE_TOKEN_SOURCE_FILE\\\"]\" -c check_for_update_on_startup=false -m gpt-6-sol" + }, + "call_generation": null + }, + { + "kind": "delivery_in_parent_conversation", + "label": "dm", + "parent_session": "01a0e98c-6399-7003-b3f7-3cfcd5c7a295", + "turn_id": "01a0e98c-8710-7402-b313-45c3b4ff9508", + "seq": 3, + "delivery_id": "07ccae7b-d892-4475-bc5d-8df9e0a8b046", + "entry": { + "uuid": null, + "type": "response_item:message:user", + "role": "user" + }, + "native_hook": "stop", + "boundary": { + "encoding": "codex_hook_prompt_xml_text", + "raw_body_equal": false + }, + "native_queue": [], + "native_ack_id": "01a0e98d-0160-7091-bd8b-2378dc509829" + }, + { + "kind": "parent_reply_after_delivery", + "label": "dm", + "parent_session": "01a0e98c-6399-7003-b3f7-3cfcd5c7a295", + "turn_id": "01a0e98c-8710-7402-b313-45c3b4ff9508", + "seq": 3, + "entry_uuid": null + }, + { + "kind": "delivery_in_parent_conversation", + "label": "multiple", + "parent_session": "01a0e98c-6399-7003-b3f7-3cfcd5c7a295", + "turn_id": "01a0e98c-8710-7402-b313-45c3b4ff9508", + "seq": 4, + "delivery_id": "a080fc61-f4ef-433b-92ca-2c5d3c629d5f", + "entry": { + "uuid": null, + "type": "response_item:message:user", + "role": "user" + }, + "native_hook": "stop", + "boundary": { + "encoding": "codex_hook_prompt_xml_text", + "raw_body_equal": false + }, + "native_queue": [], + "native_ack_id": "01a0e98d-8636-7de1-a4f1-eeab14000eae" + }, + { + "kind": "parent_reply_after_delivery", + "label": "multiple", + "parent_session": "01a0e98c-6399-7003-b3f7-3cfcd5c7a295", + "turn_id": "01a0e98c-8710-7402-b313-45c3b4ff9508", + "seq": 4, + "entry_uuid": null + }, + { + "kind": "delivery_in_parent_conversation", + "label": "all", + "parent_session": "01a0e98c-6399-7003-b3f7-3cfcd5c7a295", + "turn_id": "01a0e98c-8710-7402-b313-45c3b4ff9508", + "seq": 5, + "delivery_id": "d7cc1862-c411-42da-8c31-69b9c31154d2", + "entry": { + "uuid": null, + "type": "response_item:message:user", + "role": "user" + }, + "native_hook": "stop", + "boundary": { + "encoding": "codex_hook_prompt_xml_text", + "raw_body_equal": false + }, + "native_queue": [], + "native_ack_id": "01a0e98d-ade7-71c3-b845-e4ce0d8b41e7" + }, + { + "kind": "parent_reply_after_delivery", + "label": "all", + "parent_session": "01a0e98c-6399-7003-b3f7-3cfcd5c7a295", + "turn_id": "01a0e98c-8710-7402-b313-45c3b4ff9508", + "seq": 5, + "entry_uuid": null + } + ], + "receipts": [ + { + "seq": 3, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "codex_hook_output_complete", + "receipt_revision": 2, + "queued_submission_id": "01a0e98d-0160-7091-bd8b-2378dc509829", + "accepted_at": "2026-09-28T19:45:27.399Z" + }, + { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "codex_hook_output_complete", + "receipt_revision": 2, + "queued_submission_id": "01a0e98d-8636-7de1-a4f1-eeab14000eae", + "accepted_at": "2026-09-28T19:46:01.403Z" + }, + { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "codex_hook_output_complete", + "receipt_revision": 2, + "queued_submission_id": "01a0e98d-ade7-71c3-b845-e4ce0d8b41e7", + "accepted_at": "2026-09-28T19:46:11.564Z" + } + ], + "body_checks": [ + { + "label": "dm", + "original": { + "room": "parent-acceptance-codex-adc9714a-8e25-4b65-bd03-222232fb692f", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_DM_bced905e-46fe-4d60-8190-ee69ad6a70ff です。" + }, + "received": { + "room": "parent-acceptance-codex-adc9714a-8e25-4b65-bd03-222232fb692f", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_DM_bced905e-46fe-4d60-8190-ee69ad6a70ff です。" + } + }, + { + "label": "multiple", + "original": { + "room": "parent-acceptance-codex-adc9714a-8e25-4b65-bd03-222232fb692f", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_MULTIPLE_c1519edb-e1b3-4f4b-bd36-97ab2478e5d2 です。" + }, + "received": { + "room": "parent-acceptance-codex-adc9714a-8e25-4b65-bd03-222232fb692f", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_MULTIPLE_c1519edb-e1b3-4f4b-bd36-97ab2478e5d2 です。" + } + }, + { + "label": "all", + "original": { + "room": "parent-acceptance-codex-adc9714a-8e25-4b65-bd03-222232fb692f", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_ALL_69981e9a-7986-4b0b-84d7-7f380f566c59 です。" + }, + "received": { + "room": "parent-acceptance-codex-adc9714a-8e25-4b65-bd03-222232fb692f", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_ALL_69981e9a-7986-4b0b-84d7-7f380f566c59 です。" + } + } + ], + "checks": [ + { + "status": "passed", + "reasons": [], + "label": "dm", + "recipient": "bell", + "original": { + "room": "parent-acceptance-codex-adc9714a-8e25-4b65-bd03-222232fb692f", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_DM_bced905e-46fe-4d60-8190-ee69ad6a70ff です。" + }, + "received": { + "room": "parent-acceptance-codex-adc9714a-8e25-4b65-bd03-222232fb692f", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_DM_bced905e-46fe-4d60-8190-ee69ad6a70ff です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-codex-adc9714a-8e25-4b65-bd03-222232fb692f", + "from": "probe", + "to": "bell", + "seq": 3, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_DM_bced905e-46fe-4d60-8190-ee69ad6a70ff です。", + "delivery_id": "07ccae7b-d892-4475-bc5d-8df9e0a8b046", + "digest": "c6eb107ac0b77905c01ec0e5e98bc5791917bf06c4d03ea746d8fad88a3d4d60", + "preview": false, + "order": 37, + "session": "01a0e98c-6399-7003-b3f7-3cfcd5c7a295", + "turn_id": "01a0e98c-8710-7402-b313-45c3b4ff9508", + "hook": "stop", + "boundary": { + "encoding": "codex_hook_prompt_xml_text", + "raw_body_equal": false + }, + "entry": { + "uuid": null, + "type": "response_item:message:user", + "role": "user" + } + }, + "reply": { + "order": 40, + "session": "01a0e98c-6399-7003-b3f7-3cfcd5c7a295", + "turn_id": "01a0e98c-8710-7402-b313-45c3b4ff9508", + "text": "符号:`PEERTABLE_ACCEPT_CODEX_DM_bced905e-46fe-4d60-8190-ee69ad6a70ff`。送信者:`probe`、宛先:`bell`、seq:`3`。" + }, + "spool": { + "delivery_id": "07ccae7b-d892-4475-bc5d-8df9e0a8b046", + "state": "submitted", + "queued_submission_id": "01a0e98d-0160-7091-bd8b-2378dc509829", + "accepted_at": "2026-09-28T19:45:27.399Z", + "reason": "codex_hook_output_complete" + }, + "receipt": { + "seq": 3, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "codex_hook_output_complete", + "receipt_revision": 2, + "queued_submission_id": "01a0e98d-0160-7091-bd8b-2378dc509829", + "accepted_at": "2026-09-28T19:45:27.399Z" + } + }, + { + "status": "passed", + "reasons": [], + "label": "multiple", + "recipient": "bell", + "original": { + "room": "parent-acceptance-codex-adc9714a-8e25-4b65-bd03-222232fb692f", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_MULTIPLE_c1519edb-e1b3-4f4b-bd36-97ab2478e5d2 です。" + }, + "received": { + "room": "parent-acceptance-codex-adc9714a-8e25-4b65-bd03-222232fb692f", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_MULTIPLE_c1519edb-e1b3-4f4b-bd36-97ab2478e5d2 です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-codex-adc9714a-8e25-4b65-bd03-222232fb692f", + "from": "probe", + "to": [ + "bell", + "rui" + ], + "seq": 4, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_MULTIPLE_c1519edb-e1b3-4f4b-bd36-97ab2478e5d2 です。", + "delivery_id": "a080fc61-f4ef-433b-92ca-2c5d3c629d5f", + "digest": "c0ee7faaa5b6fb535f85956fb28d02164be12e63ba4df26c0299e3ba0e8069a1", + "preview": false, + "order": 43, + "session": "01a0e98c-6399-7003-b3f7-3cfcd5c7a295", + "turn_id": "01a0e98c-8710-7402-b313-45c3b4ff9508", + "hook": "stop", + "boundary": { + "encoding": "codex_hook_prompt_xml_text", + "raw_body_equal": false + }, + "entry": { + "uuid": null, + "type": "response_item:message:user", + "role": "user" + } + }, + "reply": { + "order": 46, + "session": "01a0e98c-6399-7003-b3f7-3cfcd5c7a295", + "turn_id": "01a0e98c-8710-7402-b313-45c3b4ff9508", + "text": "符号:`PEERTABLE_ACCEPT_CODEX_MULTIPLE_c1519edb-e1b3-4f4b-bd36-97ab2478e5d2`。送信者:`probe`、宛先:`[\"bell\",\"rui\"]`、seq:`4`。" + }, + "spool": { + "delivery_id": "a080fc61-f4ef-433b-92ca-2c5d3c629d5f", + "state": "submitted", + "queued_submission_id": "01a0e98d-8636-7de1-a4f1-eeab14000eae", + "accepted_at": "2026-09-28T19:46:01.403Z", + "reason": "codex_hook_output_complete" + }, + "receipt": { + "seq": 4, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "codex_hook_output_complete", + "receipt_revision": 2, + "queued_submission_id": "01a0e98d-8636-7de1-a4f1-eeab14000eae", + "accepted_at": "2026-09-28T19:46:01.403Z" + } + }, + { + "status": "passed", + "reasons": [], + "label": "all", + "recipient": "bell", + "original": { + "room": "parent-acceptance-codex-adc9714a-8e25-4b65-bd03-222232fb692f", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_ALL_69981e9a-7986-4b0b-84d7-7f380f566c59 です。" + }, + "received": { + "room": "parent-acceptance-codex-adc9714a-8e25-4b65-bd03-222232fb692f", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_ALL_69981e9a-7986-4b0b-84d7-7f380f566c59 です。" + }, + "body_equal": true, + "count": 1, + "delivery": { + "room": "parent-acceptance-codex-adc9714a-8e25-4b65-bd03-222232fb692f", + "from": "probe", + "to": "all", + "seq": 5, + "body": "日本語の観測\n「引用」😀 > 引用行 & 'q' 字面>\n配送確認の符号は PEERTABLE_ACCEPT_CODEX_ALL_69981e9a-7986-4b0b-84d7-7f380f566c59 です。", + "delivery_id": "d7cc1862-c411-42da-8c31-69b9c31154d2", + "digest": "90d6ec094c72d0903b23a09fb955f7150f55990892ea91de30361bc985710c59", + "preview": false, + "order": 49, + "session": "01a0e98c-6399-7003-b3f7-3cfcd5c7a295", + "turn_id": "01a0e98c-8710-7402-b313-45c3b4ff9508", + "hook": "stop", + "boundary": { + "encoding": "codex_hook_prompt_xml_text", + "raw_body_equal": false + }, + "entry": { + "uuid": null, + "type": "response_item:message:user", + "role": "user" + } + }, + "reply": { + "order": 52, + "session": "01a0e98c-6399-7003-b3f7-3cfcd5c7a295", + "turn_id": "01a0e98c-8710-7402-b313-45c3b4ff9508", + "text": "符号:`PEERTABLE_ACCEPT_CODEX_ALL_69981e9a-7986-4b0b-84d7-7f380f566c59`。送信者:`probe`、宛先:`all`、seq:`5`。" + }, + "spool": { + "delivery_id": "d7cc1862-c411-42da-8c31-69b9c31154d2", + "state": "submitted", + "queued_submission_id": "01a0e98d-ade7-71c3-b845-e4ce0d8b41e7", + "accepted_at": "2026-09-28T19:46:11.564Z", + "reason": "codex_hook_output_complete" + }, + "receipt": { + "seq": 5, + "recipient": "bell", + "route": "parent_receiver", + "result": "delivered", + "reason": "codex_hook_output_complete", + "receipt_revision": 2, + "queued_submission_id": "01a0e98d-ade7-71c3-b845-e4ce0d8b41e7", + "accepted_at": "2026-09-28T19:46:11.564Z" + } + } + ], + "transcript_rows": 56, + "turns_seen": [ + "01a0e98c-8710-7402-b313-45c3b4ff9508" + ] +} diff --git a/rag/parent-delivery/snapshots/017bf2c/product-acceptance.json b/rag/parent-delivery/snapshots/017bf2c/product-acceptance.json new file mode 100644 index 00000000..37481dd3 --- /dev/null +++ b/rag/parent-delivery/snapshots/017bf2c/product-acceptance.json @@ -0,0 +1,87 @@ +{ + "schema": "peertable.parent-product-acceptance.v1", + "records": [ + { + "id": "darwin/claude/cli/audience", + "status": "passed", + "kind": "product_live", + "native": true, + "harness_version": "2.1.284 (Claude Code)", + "source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "runtime_digest": "aab616d50a6a0ec7752f492e1f28cfa73f6afb501b31a3d3eae1041584bfc2f2", + "package_version": "0.8.63", + "parent_session": "d9481638-df40-460f-8d51-fa3feb29a8f5", + "evidence_file": "rag/parent-delivery/live/darwin/claude/cli/audience.json", + "body_equal": true, + "owner_input_required": false, + "receipt_evidence": "3:bell:r1,4:bell:r1,5:bell:r1", + "observations": [ + "parent_join", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery" + ], + "audiences": { + "dm": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "multiple": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "all": { + "count": 1, + "body_equal": true, + "status": "passed" + } + } + }, + { + "id": "darwin/codex/cli/audience", + "status": "passed", + "kind": "product_live", + "native": true, + "harness_version": "codex-cli 0.155.1", + "source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "runtime_digest": "aab616d50a6a0ec7752f492e1f28cfa73f6afb501b31a3d3eae1041584bfc2f2", + "package_version": "0.8.63", + "parent_session": "01a0e989-24a0-7463-8cfd-11fcab8cf770", + "evidence_file": "rag/parent-delivery/live/darwin/codex/cli/audience.json", + "body_equal": true, + "owner_input_required": false, + "receipt_evidence": "3:bell:r2,4:bell:r1,5:bell:r1", + "observations": [ + "parent_join", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery", + "delivery_in_parent_conversation", + "parent_reply_after_delivery" + ], + "audiences": { + "dm": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "multiple": { + "count": 1, + "body_equal": true, + "status": "passed" + }, + "all": { + "count": 1, + "body_equal": true, + "status": "passed" + } + } + } + ] +} diff --git a/rag/parent-delivery/snapshots/017bf2c/release-candidate-checkpoint.json b/rag/parent-delivery/snapshots/017bf2c/release-candidate-checkpoint.json new file mode 100644 index 00000000..264ccb99 --- /dev/null +++ b/rag/parent-delivery/snapshots/017bf2c/release-candidate-checkpoint.json @@ -0,0 +1,51 @@ +{ + "schema": "peertable.parent-release-checkpoint.v1", + "source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "runtime_digest": "aab616d50a6a0ec7752f492e1f28cfa73f6afb501b31a3d3eae1041584bfc2f2", + "package_version": "0.8.63", + "product_ci": { + "url": "https://github.com/kitepon/peertable/actions/runs/36473398201", + "head_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "status": "success", + "native_os": { + "darwin": "passed", + "linux": "passed", + "win32": "passed" + } + }, + "documentation_tests": { + "passed": 6, + "failed": 0, + "skipped": 0 + }, + "package": { + "filename": "peertable-0.8.63.tgz", + "sha256": "9094a3a53b4d66b6322c9ca1e4f23e93810a760d79872b862fe73e26b85dd774", + "pack_dry_run_files": 131, + "pack_files": 131, + "parent_process_included": true, + "install_source": "npm_pack_tarball", + "install_scope": "fixture_local_prefix", + "diagnostics": "ready", + "registry_install": false + }, + "product_acceptance": { + "manifest": "rag/parent-delivery/product-acceptance.json", + "status": "failed", + "expected": 525, + "records": 2, + "errors": { + "PARENT_ACCEPTANCE_MISSING": 523 + }, + "checkpoint_counts_as_live_acceptance": false + }, + "gui_observation": { + "status": "blocked", + "tool": "jev-desktop", + "alternative_permission_pending": true, + "test_windows_closed": true + }, + "published": false, + "production_deployed": false, + "registry_install": false +} From 7e3eb27bb3173b0e53a1d86a6ea7c81bcf290268 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:27:21 +0900 Subject: [PATCH 47/72] =?UTF-8?q?OS=E4=BF=AE=E7=90=86=E5=BE=8C=E3=81=AE3?= =?UTF-8?q?=20OS=E8=A3=BD=E5=93=81CI=E5=90=88=E6=A0=BC=E3=82=92=E5=85=AC?= =?UTF-8?q?=E9=96=8B=E3=83=81=E3=82=A7=E3=83=83=E3=82=AF=E3=83=9D=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=83=88=E3=81=B8=E8=A8=98=E9=8C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../release-candidate-checkpoint.json | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/rag/parent-delivery/release-candidate-checkpoint.json b/rag/parent-delivery/release-candidate-checkpoint.json index 0b336632..77f5e989 100644 --- a/rag/parent-delivery/release-candidate-checkpoint.json +++ b/rag/parent-delivery/release-candidate-checkpoint.json @@ -7,9 +7,15 @@ "source_frozen": false, "previous_candidate_checkpoint": "rag/parent-delivery/snapshots/017bf2c/release-candidate-checkpoint.json", "product_ci": { - "status": "pending_new_product_source", - "previous_source_commit": "017bf2c6342f573b23fa8b00c19ae34d0d291392", - "previous_url": "https://github.com/kitepon/peertable/actions/runs/36473398201" + "status": "success", + "source_commit": "356f9c408bfb8a46327651130b513b21b1b2842a", + "head_commit": "590f5a8b65c0a00b7b0877495da2c6a65428101c", + "url": "https://github.com/kitepon/peertable/actions/runs/36478777216", + "native_os": { + "darwin": "passed", + "linux": "passed", + "win32": "passed" + } }, "focused_verification": { "darwin_process_exit": { From d3a91b14af6d122e5323214034198a43ce4e0e8d Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:34:06 +0900 Subject: [PATCH 48/72] =?UTF-8?q?Cursor=E5=AE=8C=E6=88=90hook=E3=81=AE?= =?UTF-8?q?=E5=85=A8=E5=85=A5=E5=8A=9B=E3=82=92=E5=8F=97=E4=BF=A1=E7=99=BB?= =?UTF-8?q?=E9=8C=B2=E3=81=A8=E4=BD=9C=E6=A5=AD=E4=BC=9A=E8=A9=B1=E3=81=A7?= =?UTF-8?q?=E5=8E=B3=E5=AF=86=E7=85=A7=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scenarios-context.mjs | 10 +++++++++- .../scenarios-native.mjs | 20 ++++++++++++------- .../scenarios-native.test.mjs | 11 ++++++++-- .../scenarios.test.mjs | 6 ++++-- 4 files changed, 35 insertions(+), 12 deletions(-) diff --git a/experiments/parent-product-acceptance/scenarios-context.mjs b/experiments/parent-product-acceptance/scenarios-context.mjs index 8e3da9b2..f24b4025 100644 --- a/experiments/parent-product-acceptance/scenarios-context.mjs +++ b/experiments/parent-product-acceptance/scenarios-context.mjs @@ -134,6 +134,14 @@ export function assertCodexBusy({ workTurn, delivery, record, turns }) { return native } +// Cursor Shellの完成済み入力はpostToolUseが渡す全体を使う。model入力は別の原証拠として保持する。 +export function completedNativeInput(harness, use) { + return harness === 'cursor' ? use.hook_input : use.input +} +export function completedNativeInputProof(harness, use) { + return { completed_input: completedNativeInput(harness, use), model_input: use.input, hook_input: use.hook_input, official_stage: harness === 'cursor' ? 'postToolUse' : 'native_tool', tool_use_id: use.id, session: use.session, turn_id: use.turn_id } +} + // Shellという名前だけでは許可しない。製品が確定した入力と実task/PIDの相関を要求する。 export function nativeReceiveToolAllowed(use, registrations, session) { return registrations.some(item => { @@ -143,7 +151,7 @@ export function nativeReceiveToolAllowed(use, registrations, session) { const taskId = use.native_task_id ?? output?.task_id ?? output?.shell_id const pid = use.native_task_pid ?? output?.pid return item.owner_verified === true && item.parent_session === session && (use.session ?? use.parent_session) === session - && use.name === task.input?.name && isDeepStrictEqual(use.input, task.input?.input) + && use.name === task.input?.name && isDeepStrictEqual(completedNativeInput(task.input?.name === 'Shell' ? 'cursor' : 'grok', use), task.input?.input) && String(taskId ?? '') === task.id && pid === task.pid && item.waiter_owner.pid > 0 && item.waiter_owner.started && task.process_identity?.pid === pid && task.process_identity.started }) diff --git a/experiments/parent-product-acceptance/scenarios-native.mjs b/experiments/parent-product-acceptance/scenarios-native.mjs index 91dcd9e4..f52f3b88 100644 --- a/experiments/parent-product-acceptance/scenarios-native.mjs +++ b/experiments/parent-product-acceptance/scenarios-native.mjs @@ -5,7 +5,7 @@ import { pathToFileURL } from 'node:url' import { randomUUID } from 'node:crypto' import { isDeepStrictEqual } from 'node:util' import { execFileSync } from 'node:child_process' -import { createScenarioContext } from './scenarios-context.mjs' +import { createScenarioContext, completedNativeInput, completedNativeInputProof } from './scenarios-context.mjs' import { createNativeFixtureFactory, waitOwnedFixtureExit } from './scenarios-fixtures.mjs' import { sha256 } from './evidence.mjs' import { createBackgroundSurfaceAdapters, nativeInvocation } from './scenarios-surfaces.mjs' @@ -166,10 +166,10 @@ export function nativeLeaseRegistration(target, { sameProcess, processDescendsFr const native = waiter.native_task if (!native || !sameProcess(native.process_identity) || !processDescendsFrom(waiter.owner, native.process_identity)) return null const seen = target.observe(), expectedName = target.meta.harness === 'cursor' ? 'Shell' : 'run_terminal_command' - const use = seen.toolUses.find(use => use.name === expectedName && use.session === target.meta.parent_session && isDeepStrictEqual(use.input, native.input?.input)) + const use = seen.toolUses.find(use => use.name === expectedName && use.session === target.meta.parent_session && isDeepStrictEqual(completedNativeInput(target.meta.harness, use), native.input?.input)) const task = use && seen.tasks?.find(task => task.tool_use_id === use.id && String(task.task_id ?? task.id) === native.id && task.pid === native.pid) if (!use?.turn_id || !task || native.input?.name !== expectedName) return null - return { state: current, waiter, registration: { tool_use_id: use.id, turn_id: use.turn_id, task_id: native.id, input: use.input, owner: native.process_identity, native_task: task } } + return { state: current, waiter, registration: { tool_use_id: use.id, turn_id: use.turn_id, task_id: native.id, input: completedNativeInput(target.meta.harness, use), input_proof: completedNativeInputProof(target.meta.harness, use), owner: native.process_identity, native_task: task } } } // 再開は停止時の所有証拠を使う。native親の終了でown receiverの回収を妨げない。 @@ -221,13 +221,13 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { if (!existsSync(identity)) return null const owner = s.processIdentity(JSON.parse(readFileSync(identity, 'utf8')).pid), seen = target.observe() if (!owner || !s.sameProcess(owner) || !s.processDescendsFrom(owner, target.meta.parent_process) || existsSync(completed)) fail('ACCEPTANCE_WORK_NATIVE_OWNER', '専用親の存命作業processではありません') - const use = seen.toolUses.find(use => use.order >= before && use.name === (s.harness === 'cursor' ? 'Shell' : 'run_terminal_command') && isDeepStrictEqual(use.input, input)) + const use = seen.toolUses.find(use => use.order >= before && use.session === target.meta.parent_session && use.name === (s.harness === 'cursor' ? 'Shell' : 'run_terminal_command') && isDeepStrictEqual(completedNativeInput(s.harness, use), input)) if (!use?.turn_id) return null const task = (seen.tasks ?? []).find(task => task.tool_use_id === use.id && task.pid && (task.pid === owner.pid || s.processDescendsFrom(owner, s.processIdentity(task.pid)))) return task ? { owner, use, task } : null }, 120000, 100) Object.assign(state(s).work, observed) - return s.result(s, 'work_running', 'native_task', { work_process: observed.owner, work_native_task: observed.task, work_tool_use_id: observed.use.id, work_turn_id: observed.use.turn_id, work_input: input }) + return s.result(s, 'work_running', 'native_task', { work_process: observed.owner, work_native_task: observed.task, work_tool_use_id: observed.use.id, work_turn_id: observed.use.turn_id, work_input: input, work_input_proof: completedNativeInputProof(s.harness, observed.use) }) } actions.work_finish = async (_, s) => { const work = state(s).work, target = self(s), check = confirmOne(s) @@ -326,9 +326,15 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { await fixture.submit(`新会話の実相関試験です。${s.harness === 'cursor' ? 'Shell' : 'run_terminal_command'}へ次の入力を渡し、出力をそのまま報告してください。Peertable joinはまだ呼ばないでください。input: ${JSON.stringify(input)}`) const event = await s.until('新CLIの実hook CID', () => fixture.adapter.hookEvents().slice(beforeEvents).find(row => nativeSession(row.event) && nativeSession(row.event) !== old.meta.parent_session && JSON.stringify(row.event).includes(marker)), 120000, 100) const newSession = nativeSession(event.event), file = await s.until('新会話の公式transcript', () => fixture.adapter.transcript(newSession), 30000, 100) - const reply = await s.until('新CLIの実会話応答', () => fixture.adapter.read(file, { session: newSession }).replies.find(reply => reply.text.includes(marker) && reply.turn_id), 120000, 100) + const observed = await s.until('新CLIの完成済み入力と実会話応答', () => { + const seen = fixture.adapter.read(file, { session: newSession }) + const use = seen.toolUses.find(use => use.session === newSession && use.id === (event.event.tool_use_id ?? event.event.toolUseId) && use.name === (s.harness === 'cursor' ? 'Shell' : 'run_terminal_command') && isDeepStrictEqual(completedNativeInput(s.harness, use), input)) + const reply = use && seen.replies.find(reply => reply.session === newSession && reply.order > use.order && reply.text.includes(marker) && reply.turn_id) + return reply ? { use, reply } : null + }, 120000, 100) + const { reply } = observed state(s).session = { old, fixture, clear: event, newSession } - return s.result(s, 'new_conversation_identity', 'harness_transcript', { old_session: old.meta.parent_session, related_session: newSession, related_turn_id: reply.turn_id, new_transcript: file, official_event_artifact: fixture.adapter.hookFile, mechanism: 'own CLI終了→公式CLI新規起動' }) + return s.result(s, 'new_conversation_identity', 'harness_transcript', { old_session: old.meta.parent_session, related_session: newSession, related_turn_id: reply.turn_id, new_transcript: file, input_proof: completedNativeInputProof(s.harness, observed.use), official_event_artifact: fixture.adapter.hookFile, mechanism: 'own CLI終了→公式CLI新規起動' }) } const command = s.harness === 'claude' ? '/clear' : s.harness === 'codex' ? '/new' : fixture.adapter.clear if (!command) sourceUnconfirmed(s.harness, '公式新会話command') diff --git a/experiments/parent-product-acceptance/scenarios-native.test.mjs b/experiments/parent-product-acceptance/scenarios-native.test.mjs index 4d81f13d..bb6ecf8a 100644 --- a/experiments/parent-product-acceptance/scenarios-native.test.mjs +++ b/experiments/parent-product-acceptance/scenarios-native.test.mjs @@ -130,12 +130,19 @@ test('実processで独立receiverを停止し、親が先に終了しても再 test('lease開始はverified後の未登録期間を待ち、公式tool/task登録と本人を照合する', () => { const owner = { pid: 200, started: 'own開始' }, input = { command: '完成済み', cwd: '/専用' }, native = { id: 'own-task', pid: owner.pid, process_identity: owner, input: { name: 'Shell', input } } - const current = { state: 'verified', runtime: 'rearm_pending', waiter: null }, use = { id: 'use', turn_id: 'turn', name: 'Shell', session: 'own', input }, task = { id: 'own-task', tool_use_id: 'use', pid: owner.pid } + const current = { state: 'verified', runtime: 'rearm_pending', waiter: null }, use = { id: 'use', turn_id: 'turn', name: 'Shell', session: 'own', input: { command: input.command }, hook_input: input }, task = { id: 'own-task', tool_use_id: 'use', pid: owner.pid } const target = { spool: { read: () => current }, meta: { harness: 'cursor', parent_session: 'own' }, observe: () => ({ toolUses: [use], tasks: [task] }) } const options = { sameProcess: () => true, processDescendsFrom: () => true } assert.equal(nativeLeaseRegistration(target, options), null) current.runtime = 'armed'; current.waiter = { owner, native_task: native } assert.equal(nativeLeaseRegistration(target, options).registration.task_id, 'own-task') + const proof = nativeLeaseRegistration(target, options).registration + assert.deepEqual(proof.input, input) + assert.deepEqual(proof.input_proof.model_input, { command: input.command }) + assert.equal(proof.input_proof.official_stage, 'postToolUse') + use.hook_input = { ...input, cwd: '/別project' }; assert.equal(nativeLeaseRegistration(target, options), null) + use.hook_input = undefined; assert.equal(nativeLeaseRegistration(target, options), null) + use.hook_input = input use.session = '他会話'; assert.equal(nativeLeaseRegistration(target, options), null) use.session = 'own'; task.pid = 999; assert.equal(nativeLeaseRegistration(target, options), null) current.state = 'failed'; current.error_code = '実製品error' @@ -207,7 +214,7 @@ test('lease armのmodule path結合はendpoint再joinを呼ばず4harnessで文 let rejoined = 0; const fixture = { harness, adapter: {}, join: async () => { rejoined++ } } const nativeName = harness === 'cursor' ? 'Shell' : 'run_terminal_command', input = { command: '専用登録入力' } const state = { state: 'verified', runtime: 'armed', endpoint_id: 'own', watcher: owner, caller: { owner }, waiter: { owner, native_task: { id: 'task', pid: owner.pid, process_identity: owner, input: { name: nativeName, input } } } } - const target = { fixture, meta: { harness, parent_session: 'own' }, spool: { id: 'own', read: () => state }, observe: () => ({ toolUses: [{ name: nativeName, session: 'own', input, id: 'tool', turn_id: 'turn' }], tasks: [{ tool_use_id: 'tool', id: 'task', pid: owner.pid }] }) } + const target = { fixture, meta: { harness, parent_session: 'own' }, spool: { id: 'own', read: () => state }, observe: () => ({ toolUses: [{ name: nativeName, session: 'own', input, hook_input: harness === 'cursor' ? input : undefined, id: 'tool', turn_id: 'turn' }], tasks: [{ tool_use_id: 'tool', id: 'task', pid: owner.pid }] }) } const native = createNativeActions({ factory: { close: async () => {} }, primary: fixture }) const scope = { harness, runId: harness, pkg: process.cwd(), checks: [], context: { endpoint: () => target }, artifactFor: () => join(dir, harness + '.json'), processIdentity, sameProcess, processDescendsFrom, until: async (_, probe) => { const value = await probe(); assert.ok(value); return value }, result: (_, expectation, kind, detail) => ({ expectation, kind, ...detail }) } const result = await native.actions.lease_expiry_arm({}, scope) diff --git a/experiments/parent-product-acceptance/scenarios.test.mjs b/experiments/parent-product-acceptance/scenarios.test.mjs index 33912495..af0ec7e3 100644 --- a/experiments/parent-product-acceptance/scenarios.test.mjs +++ b/experiments/parent-product-acceptance/scenarios.test.mjs @@ -132,9 +132,11 @@ test('Codex idleは過去のtask_completeを別turnへ流用しない', t => { test('受信維持Shellは完成済み入力・実task ID・PID・所有相関が全て一致した場合だけ許可する', () => { const registration = { parent_session: 's', owner_verified: true, waiter_owner: { pid: 10, started: 'waiter-start' }, native_task: { id: 'task', pid: 20, process_identity: { pid: 20, started: 'task-start' }, input: { name: 'Shell', input: { command: 'exact-entry args', cwd: '/fixture' } } } } - const use = { name: 'Shell', session: 's', input: { command: 'exact-entry args', cwd: '/fixture' }, output: { shell_id: 'task', pid: 20 } } + const use = { name: 'Shell', session: 's', input: { command: 'exact-entry args' }, hook_input: { command: 'exact-entry args', cwd: '/fixture' }, output: { shell_id: 'task', pid: 20 } } assert.equal(nativeReceiveToolAllowed(use, [registration], 's'), true) - assert.equal(nativeReceiveToolAllowed({ ...use, input: { command: 'unrelated-work', cwd: '/fixture' } }, [registration], 's'), false) + assert.equal(nativeReceiveToolAllowed({ ...use, hook_input: { command: 'unrelated-work', cwd: '/fixture' } }, [registration], 's'), false) + assert.equal(nativeReceiveToolAllowed({ ...use, hook_input: { command: 'exact-entry args', cwd: '/別project' } }, [registration], 's'), false) + assert.equal(nativeReceiveToolAllowed({ ...use, hook_input: undefined }, [registration], 's'), false) assert.equal(nativeReceiveToolAllowed({ ...use, output: { shell_id: 'other-task', pid: 20 } }, [registration], 's'), false) assert.equal(nativeReceiveToolAllowed({ ...use, session: 'other-session' }, [registration], 's'), false) assert.equal(nativeReceiveToolAllowed(use, [{ ...registration, owner_verified: false }], 's'), false) From 4402c6cf761c668455527080c7ac831236b60256 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:36:54 +0900 Subject: [PATCH 49/72] =?UTF-8?q?Codex=E5=88=9D=E5=9B=9E=E5=8F=82=E5=8A=A0?= =?UTF-8?q?=E3=81=AE=E5=85=AC=E5=BC=8F=E5=8F=97=E4=BF=A1=E6=BA=96=E5=82=99?= =?UTF-8?q?=E5=BE=8C=E3=81=ABprobe=E7=A2=BA=E8=AA=8D=E6=9C=9F=E9=99=90?= =?UTF-8?q?=E3=82=92=E9=96=8B=E5=A7=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skill/scripts/parent-runtime-probe.test.mjs | 59 +++++++++++++++++++++ skill/scripts/parent-runtime.mjs | 4 +- 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 skill/scripts/parent-runtime-probe.test.mjs diff --git a/skill/scripts/parent-runtime-probe.test.mjs b/skill/scripts/parent-runtime-probe.test.mjs new file mode 100644 index 00000000..832efa2c --- /dev/null +++ b/skill/scripts/parent-runtime-probe.test.mjs @@ -0,0 +1,59 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createServer } from 'node:http' +import { mkdtempSync, rmSync, writeFileSync, realpathSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { atomicJson, processIdentity } from './parent-platform.mjs' +import { joinEndpoint, stopEndpoint, projectEndpoints } from './parent-runtime.mjs' + +test('初回Codex joinはwatcher起動前にprobe期限を開始し、背景登録待ちのCursorは開始しない', async t => { + const project = realpathSync(mkdtempSync(join(tmpdir(), 'peertable-join-probe-'))) + const credential = join(project, 'credential') + writeFileSync(credential, 'fixture', { mode: 0o600 }) + const previousCredential = process.env.PEERTABLE_CREDENTIAL_FILE + process.env.PEERTABLE_CREDENTIAL_FILE = credential + const members = [], streams = new Set() + const server = createServer(async (req, res) => { + let raw = ''; for await (const chunk of req) raw += chunk + res.setHeader('content-type', 'application/json') + const json = value => res.end(JSON.stringify(value)) + if (req.url.endsWith('/members')) { + if (req.method === 'POST') members.push(JSON.parse(raw)) + return json({ members }) + } + if (req.url.endsWith('/summary')) return json({ seq: 0 }) + if (req.url.includes('/messages')) return json({ messages: [] }) + if (req.url.endsWith('/bridges') || req.url.endsWith('/deliveries')) return json({ ok: true }) + if (req.url.endsWith('/events')) { + res.setHeader('content-type', 'text/event-stream'); res.write('event: ping\ndata: 0\n\n') + streams.add(res); res.on('close', () => streams.delete(res)); return + } + res.statusCode = 404; json({ error: 'fixture_path_unknown' }) + }) + t.after(async () => { + for (const spool of projectEndpoints(project)) await stopEndpoint(spool) + for (const stream of streams) stream.destroy() + await new Promise(resolve => server.close(resolve)) + if (previousCredential === undefined) delete process.env.PEERTABLE_CREDENTIAL_FILE + else process.env.PEERTABLE_CREDENTIAL_FILE = previousCredential + rmSync(project, { recursive: true, force: true }) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + atomicJson(join(project, '.team/setup-state.json'), { room: 'fixture', server_url: `http://127.0.0.1:${server.address().port}` }) + // joinEndpointの内部契約を直接試す。公式harness/MCPの実機受入には算入しない。 + for (const harness of ['codex', 'cursor']) { + const before = Date.now() + const spool = await joinEndpoint(project, `fixture-${harness}`, { harness, conversation: `fixture-${harness}`, owner: processIdentity(process.pid) }) + const after = Date.now(), state = spool.read() + assert.equal(state.state, 'receiving') + if (harness === 'codex') { + assert.equal(state.runtime, 'armed') + assert.ok(state.probe_deadline >= before + 30000 && state.probe_deadline <= after + 30000) + } else { + assert.equal(state.runtime, 'rearm_pending') + assert.equal(state.probe_deadline, undefined) + } + await stopEndpoint(spool) + } +}) diff --git a/skill/scripts/parent-runtime.mjs b/skill/scripts/parent-runtime.mjs index 33daccde..5be00f41 100644 --- a/skill/scripts/parent-runtime.mjs +++ b/skill/scripts/parent-runtime.mjs @@ -5,7 +5,7 @@ import { execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' import { setTimeout as delay } from 'node:timers/promises' import { randomUUID } from 'node:crypto' -import { ParentSpool } from './parent-delivery.mjs' +import { ParentSpool, armParentState } from './parent-delivery.mjs' import { atomicJson, readJson, processIdentity, sameProcess, failure, posixQuote, psQuote } from './parent-platform.mjs' import { registerEndpoint, endpointsFor, forgetEndpoint } from './parent-caller.mjs' import { ownsParentConnection } from './parent-connect.mjs' @@ -140,6 +140,8 @@ export async function joinEndpoint(projectArg, name, caller, display = {}) { spool.update({ global_connection: ownsParentConnection(caller.harness) }) await api.request('members', { method: 'POST', body: parentMemberRecord(name, caller.harness, spool.id, display) }) actorEnvironment(project, name) + // Codexはjoin前に公式queueと同期hookの準備を確認済み。初回も実probeの期限を開始する。 + if (caller.harness === 'codex') spool.transact(armParentState) await startEndpoint(spool) return spool } From 28c325b4930f39811656ce93dfd3ce5b8d380634 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:46:19 +0900 Subject: [PATCH 50/72] =?UTF-8?q?=E8=A6=AAprobe=E3=81=AE=E5=AE=9F=E6=9C=9F?= =?UTF-8?q?=E9=99=90=E3=82=92room=E5=BF=83=E6=8B=8D=E3=81=A8RPC=E5=BE=85?= =?UTF-8?q?=E6=A9=9F=E3=81=8B=E3=82=89=E7=8B=AC=E7=AB=8B=E3=81=95=E3=81=9B?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skill/scripts/parent-source-runtime.test.mjs | 39 ++++++++++++++++++++ skill/scripts/parent-watch.mjs | 37 +++++++++++++++++-- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/skill/scripts/parent-source-runtime.test.mjs b/skill/scripts/parent-source-runtime.test.mjs index 920facea..41fa2e1e 100644 --- a/skill/scripts/parent-source-runtime.test.mjs +++ b/skill/scripts/parent-source-runtime.test.mjs @@ -15,6 +15,45 @@ const waitFor = async condition => { const deadline = Date.now() + 30000 while (!condition()) { if (Date.now() >= deadline) throw new Error('観測期限を超過しました'); await delay(20) } } +test('probe実期限はSSEの心拍・新投稿を待たず、atomic再武装と成功にも追従する', async t => { + const project = mkdtempSync(join(tmpdir(), 'peertable-probe-timer-')) + let spool, watcher, stream + const health = [] + const server = createServer(async (req, res) => { + let raw = ''; for await (const chunk of req) raw += chunk + const json = value => { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(value)) } + if (req.url.endsWith('/events')) { res.writeHead(200, { 'content-type': 'text/event-stream' }); res.flushHeaders(); stream = res; return } + if (req.url.endsWith('/members')) return json({ members: [{ name: 'parent', delivery: { kind: 'parent_receiver', endpoint_id: spool.id } }] }) + if (req.url.includes('/messages')) return json({ messages: [] }) + if (req.url.endsWith('/bridges')) { health.push(JSON.parse(raw)); return json({ ok: true }) } + res.statusCode = 404; json({ error: 'fixture_path_unknown' }) + }) + t.after(async () => { + if (watcher?.exitCode === null) { watcher.kill(); await once(watcher, 'exit') } + stream?.destroy(); await new Promise(resolve => server.close(resolve)) + rmSync(project, { recursive: true, force: true }) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const base = `http://127.0.0.1:${server.address().port}`, credential = join(project, 'credential') + writeFileSync(credential, 'fixture', { mode: 0o600 }) + spool = ParentSpool.create(project, { name: 'parent', room: 'fixture', server_url: base, credential, start_seq: 0, harness: 'cursor', caller: { conversation: 'fixture', owner: processIdentity(process.pid) } }) + atomicJson(join(project, '.team', 'setup-state.json'), { room: 'fixture', server_url: base }) + watcher = spawn(process.execPath, [fileURLToPath(new URL('./parent-watch.mjs', import.meta.url)), project, 'parent', '--deliver', spool.id], { stdio: ['ignore', 'ignore', 'pipe'] }) + let stderr = ''; watcher.stderr.on('data', chunk => { stderr += chunk }) + await waitFor(() => stream) + // 成功したprobeのtimerが遅れて失敗を上書きしない。 + spool.update({ runtime: 'armed', probe_deadline: Date.now() + 200 }) + await delay(75); spool.update({ state: 'verified' }); await delay(250) + assert.equal(spool.read().state, 'verified', stderr) + assert.equal(spool.read().error_code, undefined) + const deadline = Date.now() + 200 + spool.update({ state: 'receiving', runtime: 'armed', probe_deadline: deadline }) + await waitFor(() => spool.read().error_code === 'PARENT_PROBE_TIMEOUT' && health.some(item => item.state === 'failed' && JSON.parse(item.detail).error_code === 'PARENT_PROBE_TIMEOUT')) + assert.ok(Date.now() < deadline + 1500, '心拍の25秒間隔に依存していません') + assert.equal(spool.read().runtime, 'failed') + assert.equal(spool.read().cursor, 0) + assert.equal(watcher.exitCode, null, stderr) +}) test('SSEが正常で新seqが無くてもreceipt retry/孤児回収/probe期限/healthが進む', async t => { const project = mkdtempSync(join(tmpdir(), 'peertable-source-runtime-')) let spool, watcher, eventsConnected = false, receiptFailures = 0 diff --git a/skill/scripts/parent-watch.mjs b/skill/scripts/parent-watch.mjs index 148c2f31..5fbf4636 100644 --- a/skill/scripts/parent-watch.mjs +++ b/skill/scripts/parent-watch.mjs @@ -8,7 +8,7 @@ // parent-watch.mjs [parent_name] --next // parent-watch.mjs [parent_name] --follow import { execFileSync } from 'node:child_process' -import { existsSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync, renameSync, statSync, unlinkSync, watch, writeFileSync } from 'node:fs' import { join } from 'node:path' import { resolveLatticeExecutable, resolvePostToken } from './seat-usage.mjs' import { addressedToParent as messageAddressedToParent, latticeStaffingChanged, tableStallUpdate } from './parent-watch-logic.mjs' @@ -185,6 +185,39 @@ function releaseLock() { acquireLock() process.on('exit', releaseLock) +if (spool) { + let probeTimer + const watchFailed = error => { + process.stderr.write(`PARENT_PROBE_WATCH_FAILED: ${error.message}\n`) + process.exit(1) + } + const scheduleProbe = () => { + clearTimeout(probeTimer) + const saved = spool.read() + if (saved.runtime === 'stopped' || ['verified', 'failed'].includes(saved.state) || saved.probe_deadline == null) return + probeTimer = setTimeout(() => { + try { + // 他processの再武装・成功・終了を、同じatomic状態で再照合する。 + const expired = spool.transact(current => { + if (current.runtime === 'stopped' || ['verified', 'failed'].includes(current.state) || current.probe_deadline == null || Date.now() < current.probe_deadline) return false + current.state = 'failed'; current.runtime = 'failed'; current.error_code = 'PARENT_PROBE_TIMEOUT' + return true + }) + if (expired) spool.publishHealth().catch(watchFailed) + else scheduleProbe() + } catch (error) { watchFailed(error) } + }, Math.max(0, saved.probe_deadline - Date.now())) + } + // spoolはatomic renameで更新される。fileの古いinodeではなく所有directoryを監視する。 + const probeWatch = watch(spool.root, (_, file) => { + if (file != null && String(file) !== 'spool.json') return + try { scheduleProbe() } catch (error) { watchFailed(error) } + }) + probeWatch.on('error', watchFailed) + process.on('exit', () => { clearTimeout(probeTimer); probeWatch.close() }) + scheduleProbe() +} + const addressedToParent = message => messageAddressedToParent(message, parent) function exitWhenParentStdinCloses() { @@ -372,8 +405,6 @@ async function maintainReceiver() { try { await spool.flushReceipts(new RoomApi(setup, { credential: spool.read().credential })) } catch (error) { process.stderr.write(`${error.code}: ${error.message}\n`) } if (spool.read().harness === 'codex') while (await queueCodex(spool)) {} - const status = spool.read() - if (status.state !== 'verified' && status.probe_deadline != null && Date.now() > status.probe_deadline) spool.update({ state: 'failed', runtime: 'failed', error_code: 'PARENT_PROBE_TIMEOUT' }) await spool.publishHealth() } } From e56d8c2beecdfd5fa9c1cc0318d795e883a5e9b6 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:40:02 +0900 Subject: [PATCH 51/72] =?UTF-8?q?Claude=E5=B0=82=E7=94=A8run=E3=81=AE?= =?UTF-8?q?=E5=85=AC=E5=BC=8Fhook=E7=84=A1=E5=8A=B9=E8=A8=AD=E5=AE=9A?= =?UTF-8?q?=E3=81=A8=E5=90=8CID=E3=81=AEMCP=E5=A4=B1=E6=95=97=E8=A6=B3?= =?UTF-8?q?=E6=B8=AC=E3=82=92=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scenarios-fixtures.mjs | 24 +++++++++++++++- .../scenarios-fixtures.test.mjs | 16 ++++++++++- .../scenarios-native.mjs | 28 ++++++++++++++----- 3 files changed, 59 insertions(+), 9 deletions(-) diff --git a/experiments/parent-product-acceptance/scenarios-fixtures.mjs b/experiments/parent-product-acceptance/scenarios-fixtures.mjs index 11d1e85a..8753ce63 100644 --- a/experiments/parent-product-acceptance/scenarios-fixtures.mjs +++ b/experiments/parent-product-acceptance/scenarios-fixtures.mjs @@ -52,6 +52,21 @@ export function fixtureJoinInstruction({ harness, project, name }) { return common + registration + read + 'continuation_tokenがある間は同じ配送のparent_readを完了まで続けます。その結果の次wait_processも同じ完成入力で登録してください。' } +// 共有設定fileを書かず、公式--settingsを次の自己CLI runへ渡す。同じ実会話をresumeする。 +export async function setClaudeSessionHookFault(fixture, disabled) { + if (fixture.harness !== 'claude' || !fixture.session) fail('ACCEPTANCE_CLAUDE_SESSION_FAULT_UNBOUND', '既知の専用Claude会話が必要です') + const session = fixture.session + fixture.configuration.assertHooksUnchanged() + if (disabled && !fixture.claudeHookFault) fixture.claudeHookFault = { session, originalSettings: fixture.sessionSettings } + if (!disabled && !fixture.claudeHookFault) fail('ACCEPTANCE_CLAUDE_SESSION_FAULT_MISSING', '復元対象の私物session設定がありません') + await fixture.stopOwnPane() + fixture.sessionSettings = disabled ? { ...(fixture.claudeHookFault.originalSettings ?? {}), disableAllHooks: true } : fixture.claudeHookFault.originalSettings + await fixture.launch({ resume: session }) + fixture.configuration.assertHooksUnchanged() + if (!disabled) fixture.claudeHookFault = null + return { fault: disabled ? 'disabled' : 'restored', parent_session: session, official_option: '--settings', session_settings: fixture.sessionSettings, global_sourcePath: fixture.configuration.hooks, normal_hook_bytes_equal: true, global_changed: false, official_source: 'https://code.claude.com/docs/en/hooks#disable-or-remove-hooks' } +} + export function hookConfigurationSnapshot(hooks) { const beforeBytes = existsSync(hooks) ? readFileSync(hooks) : null const beforeHooks = beforeBytes ? JSON.parse(beforeBytes.toString('utf8').replace(/^\uFEFF/u, '')) : null @@ -168,6 +183,7 @@ export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUr fixture.observations.push({ method: 'config/batchWrite', own_project: project, action: 'temporary_project_trust_removed', others_semantically_unchanged: true, toml_bytes_equal: true }) fixture.projectTrust = null } + fixture.setClaudeOwnedHookFault = disabled => setClaudeSessionHookFault(fixture, disabled) fixture.modify = (file, mutate) => { if (!contained(project, file)) fail('ACCEPTANCE_FIXTURE_WRITE_OUTSIDE', file) if (existsSync(file) && realpathSync(file) !== file) fail('ACCEPTANCE_FIXTURE_LINK_WRITE', file) @@ -258,7 +274,7 @@ export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUr fixture.launch = async ({ resume } = {}) => { const cli = adapter.resolveCli(), launcher = join(directory, 'launcher.mjs'), input = join(directory, 'launch.json') const argv = harness === 'codex' ? ['-c', 'mcp_servers.peertable_parent.env_vars=["PEERTABLE_TOKEN_SOURCE_FILE"]', '-c', 'check_for_update_on_startup=false'] : harness === 'claude' ? [] : adapter.argv?.({ project, tokenFile }) ?? [] - if (sessionSettings && harness === 'claude') { const settings = join(project, 'session-settings.json'); writeJson(settings, sessionSettings); argv.push('--settings', settings) } + if (fixture.sessionSettings && harness === 'claude') { const settings = join(project, 'session-settings.json'); writeJson(settings, fixture.sessionSettings); argv.push('--settings', settings) } if (model) argv.push(harness === 'codex' ? '-m' : '--model', model) if (resume) argv.unshift(...(harness === 'codex' ? ['resume', resume] : harness === 'claude' ? ['--resume', resume] : adapter.resumeArgs(resume))) const invocation = nativeInvocation(cli.executable, argv, { shellCommand: platform.shellCommand, interactive: true }) @@ -302,6 +318,12 @@ export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUr await fixture.submit(exit) await until('fixture親終了', () => !platform.sameProcess(fixture.owner), 30000) } + fixture.stopOwnPane = async () => { + await fixture.trackOwnProcesses() + if (fixture.pty) { await fixture.aiterm.close(fixture.pty); fixture.pty = null } + await waitOwnedFixtureExit({ readEndpoints: () => projectEndpoints(project), sameProcess: platform.sameProcess, knownOwners: [...fixture.nativeOwners, ...fixture.receiverOwners, fixture.paneOwner].filter(Boolean), indexExists: id => existsSync(join(homedir(), '.peertable/parent-receivers/endpoints', `${id}.json`)) }) + fixture.ready = false; fixture.owner = null; fixture.paneOwner = null + } fixture.close = async () => { if (fixture.closed) return const errors = [] diff --git a/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs b/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs index 62c56963..45d6cfce 100644 --- a/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs +++ b/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs @@ -7,7 +7,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { processIdentity, sameProcess } from '../../skill/scripts/parent-platform.mjs' -import { assertOnlyProjectTrustChanged, hookConfigurationSnapshot, waitOwnedFixtureExit, nativeHookObserverSource, appendGrokProductObserver, observeFixtureProcessIdentity, fixtureJoinInstruction } from './scenarios-fixtures.mjs' +import { assertOnlyProjectTrustChanged, hookConfigurationSnapshot, waitOwnedFixtureExit, nativeHookObserverSource, appendGrokProductObserver, observeFixtureProcessIdentity, fixtureJoinInstruction, setClaudeSessionHookFault } from './scenarios-fixtures.mjs' test('fixture終了は実child消失の後も製品自己停止と索引撤去を待つ', async t => { const child = spawn(process.execPath, ['-e', 'setTimeout(()=>process.exit(0),150)'], { stdio: 'ignore' }) @@ -119,3 +119,17 @@ test('Grok初期joinは実task完了後のtask_idsだけの全文readerを指示 const cursor = fixtureJoinInstruction({ harness: 'cursor', project: '/私物', name: '親' }); assert.match(cursor, /公式Readで全文/u); assert.match(cursor, /offset\/limitを付けず/u); assert.doesNotMatch(cursor, /get_command_or_subagent_output/u) assert.doesNotMatch(fixtureJoinInstruction({ harness: 'claude', project: '/私物', name: '親' }), /task_ids|Shell/u) }) + + +test('Claude hook障害は同CIDの自己CLI設定だけを変え、元session設定とglobal bytesを保持する', async () => { + const actions = [], original = { model: '設定の原値', permissions: { allow: ['Read'] } } + const fixture = { harness: 'claude', session: '自己会話', sessionSettings: original, configuration: { hooks: '/通常/settings.json', assertHooksUnchanged: () => actions.push('通常bytes照合') }, stopOwnPane: async () => actions.push('自己CLI終了'), launch: async options => actions.push({ options, settings: structuredClone(fixture.sessionSettings) }) } + const disabled = await setClaudeSessionHookFault(fixture, true) + assert.equal(disabled.global_changed, false); assert.equal(disabled.official_option, '--settings') + assert.deepEqual(actions[2], { options: { resume: '自己会話' }, settings: { ...original, disableAllHooks: true } }) + assert.equal(Object.hasOwn(original, 'disableAllHooks'), false) + await setClaudeSessionHookFault(fixture, false) + assert.equal(fixture.sessionSettings, original); assert.equal(fixture.claudeHookFault, null) + assert.deepEqual(actions[6], { options: { resume: '自己会話' }, settings: original }) + await assert.rejects(setClaudeSessionHookFault({ ...fixture, harness: 'cursor' }, true), { code: 'ACCEPTANCE_CLAUDE_SESSION_FAULT_UNBOUND' }) +}) diff --git a/experiments/parent-product-acceptance/scenarios-native.mjs b/experiments/parent-product-acceptance/scenarios-native.mjs index f52f3b88..98f16366 100644 --- a/experiments/parent-product-acceptance/scenarios-native.mjs +++ b/experiments/parent-product-acceptance/scenarios-native.mjs @@ -544,6 +544,11 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { for (const [action, fault] of [['hook_disable', 'disabled'], ['hook_untrust', 'untrusted'], ['hook_remove_file', 'file_missing']]) { actions[action] = async (_, s) => { const fixture = fixtureFor(self(s)), config = fixture.configuration + if (s.harness === 'claude' && fault === 'disabled') { + const proof = await fixture.setClaudeOwnedHookFault(true) + state(s).hookFault = { fixture, fault, proof } + return s.result(s, 'own_hook_disabled', 'official_hook', proof) + } if (s.harness === 'codex' && fault !== 'file_missing') { const proof = await fixture.setCodexOwnedHookFault(fault) state(s).hookFault = { fixture, fault, proof } @@ -560,21 +565,30 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { if (!fault) sourceUnconfirmed(s.harness, '専用sessionに限定したhook障害join') const target = self(s), before = target.observe().rows await fault.fixture.submit(`Peertable parent_joinをproject=${fault.fixture.project} name=${fault.fixture.name}で1回呼び、返されたerror codeをそのまま報告してください。再試行や設定変更はしないでください。`) - const expected = 'PARENT_CODEX_HOOK_UNTRUSTED' - const observed = await s.until('障害joinの公式tool返答', () => { + const expected = s.harness === 'claude' ? 'PARENT_CALLER_UNBOUND' : 'PARENT_CODEX_HOOK_UNTRUSTED' + const observed = await s.until('障害joinの同tool ID公式MCP返答', () => { const seen = target.observe(), uses = seen.toolUses.filter(use => use.order >= before && /parent_join$/u.test(use.name ?? '')) for (const use of uses) { - const output = seen.toolUses.find(row => row.name === 'output' && row.id === use.id && row.order > use.order && productToolError(row.output, expected)) - if (output) return { use, output } + const result = bindingToolError({ harness: s.harness, file: target.file, session: target.meta.parent_session, useId: use.id, seen, expected }) + if (result) return result } return null }, 120000, 100) const failed = target.spool.read() - const error = productToolError(observed.output.output, expected) + const error = observed.error if (!error) fail('ACCEPTANCE_HOOK_JOIN_NOT_TYPED_FAILED', '公式MCP parent-errorのschema/state/error_codeが一致しません') - return s.result(s, fault.fault === 'disabled' ? 'disabled_hook_typed_failure' : 'untrusted_hook_typed_failure', 'harness_transcript', { error_code: error.error_code, existing_endpoint_state: failed.state, failure_stage: 'joinEndpointより前の公式receiver検証', tool_use_id: observed.use.id, official_tool_output: observed.output.output, parent_session: target.meta.parent_session, turn_id: observed.output.turn_id, sourcePath: fault.proof.global_sourcePath, local_config: fault.proof.local_config }) + return s.result(s, fault.fault === 'disabled' ? 'disabled_hook_typed_failure' : 'untrusted_hook_typed_failure', 'harness_transcript', { error_code: error.error_code, existing_endpoint_state: failed.state, failure_stage: s.harness === 'claude' ? '公式PreToolUse context無し、joinEndpointより前のconsumeCaller' : 'joinEndpointより前の公式receiver検証', tool_use_id: observed.use.id, official_tool_output: observed.output, parent_session: target.meta.parent_session, turn_id: observed.use.turn_id ?? target.observe().replies.at(-1)?.turn_id, sourcePath: fault.proof.global_sourcePath, local_config: fault.proof.local_config }) + } + actions.hook_restore = async (_, s) => { + const fixture = fixtureFor(self(s)) + let restored = null + if (fixture.claudeHookFault) { + restored = await fixture.setClaudeOwnedHookFault(false) + const target = await fixture.join(); s.context.registerEndpoint('self', target) + } + fixture.configuration.assertHooksUnchanged() + return s.result(s, 'normal_launch_intact', 'official_hook', { configuration_unchanged: true, restored }) } - actions.hook_restore = async (_, s) => { fixtureFor(self(s)).configuration.assertHooksUnchanged(); return s.result(s, 'normal_launch_intact', 'official_hook', { configuration_unchanged: true }) } // bind期限は製品が作ったcontextを読取り、同期hookとMCPの間を実時間で跨がせる。 actions.binding_timeout_arm = async (_, s) => { From 84de74a097469ef4abd43698635805696b1302ff Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:44:45 +0900 Subject: [PATCH 52/72] =?UTF-8?q?=E5=85=AC=E5=BC=8FWindows=20Grok=E3=81=AE?= =?UTF-8?q?=E7=BD=AB=E7=B7=9A=E5=86=85prompt=E3=82=92=E5=8E=9F=E7=94=BB?= =?UTF-8?q?=E9=9D=A2fixture=E3=81=A7=E4=BF=AE=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scenarios-surfaces.mjs | 2 +- .../scenarios-surfaces.test.mjs | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/experiments/parent-product-acceptance/scenarios-surfaces.mjs b/experiments/parent-product-acceptance/scenarios-surfaces.mjs index 04ad14cf..bbecde40 100644 --- a/experiments/parent-product-acceptance/scenarios-surfaces.mjs +++ b/experiments/parent-product-acceptance/scenarios-surfaces.mjs @@ -98,7 +98,7 @@ export async function createBackgroundSurfaceAdapters({ pkg, tokenFile, backgrou if (/authentication required|Sign in to Cursor|not logged in/iu.test(screen)) return { blocked: 'official_auth_required' } if (harness === 'cursor' && /Add a follow-up|Cursor Agent/u.test(screen) && /Run Everything/u.test(screen)) return { ready: true } if (harness === 'grok' && /trust.*(?:folder|directory)|(?:folder|directory).*trust/iu.test(screen) && /\by\b|Yes/u.test(screen)) return { keys: ['y', 'Enter'], reason: 'own_grok_project_trust' } - if (harness === 'grok' && /Grok 4|grok.*build/iu.test(screen) && /❯|›|>\s*$/mu.test(screen)) return { ready: true } + if (harness === 'grok' && /Grok 4|grok.*build/iu.test(screen) && (/❯|›|>\s*$/mu.test(screen) || /^[ \t]*│ >[ \t]*│[ \t]*$/mu.test(screen))) return { ready: true } return null }, nativeTaskOutputPath: id => harness === 'cursor' ? join(homedir(), '.cursor/projects', fixture.project.replace(/[^a-zA-Z0-9]+/gu, '-').replace(/^-+/u, ''), 'terminals', `${id}.txt`) : null, diff --git a/experiments/parent-product-acceptance/scenarios-surfaces.test.mjs b/experiments/parent-product-acceptance/scenarios-surfaces.test.mjs index 8105cef2..86c12cd6 100644 --- a/experiments/parent-product-acceptance/scenarios-surfaces.test.mjs +++ b/experiments/parent-product-acceptance/scenarios-surfaces.test.mjs @@ -114,3 +114,16 @@ test('期限通知のGrok readerは同会話taskの完了snapshotと全文byte delete reader.input.timeout_ms; read.raw_output.raw_output_bytes += 1 assert.throws(() => assertNativeTaskRead(options), { code: 'ACCEPTANCE_GROK_NATIVE_FULL_READ' }) }) + + +// 公式Windows Grok 1.0.41の5〜30秒保存画面。原保存ready-screen.txt SHA256 1a5560e2cd271d39a930fd13d897ecbac9336d16bf796ced89dfd309284de6a4 +const windowsGrok1041Screen = "\n ≡ main ~/q/diag-startup/proj\n\n\n ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮\n │ │\n │ Grok Build 1.0.41 │\n │ │\n │ Grok 4.7 is here! │\n │ Select 'Grok 4.7' under /model. │\n │ │\n │ New worktree ctrl+w │\n │ Resume session ctrl+r │\n │ Changelog │\n │ Quit ctrl+q │\n │ │\n ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯\n\n\n\n\n\n Tip: Try out workflows using /workflows.\n\n ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮\n │ > │\n ╰─────────────────────────────────────────────────────────────────────────────── Grok 4.6 (high) · always-approve ─╯\n\n [stable]" +test('公式Windows Grok 1.0.41の罫線内の空promptをstartupとして判定する', async t => { + const directory = mkdtempSync(join(tmpdir(), 'peertable-grok-startup-')); t.after(() => rmSync(directory, { recursive: true, force: true })) + const adapters = await createBackgroundSurfaceAdapters({ pkg: process.cwd(), tokenFile: join(directory, 'token'), backgroundObserverFactory: () => { throw new Error('この試験ではobserverを起動しません') } }) + const adapter = adapters.grok.bind({ directory, project: join(directory, 'project') }) + assert.equal(/❯|›|>\s*$/mu.test(windowsGrok1041Screen), false) + assert.deepEqual(adapter.startup(windowsGrok1041Screen), { ready: true }) + assert.equal(adapter.startup(windowsGrok1041Screen.replace(/Grok/gu, '別製品')), null) + assert.equal(adapter.startup(windowsGrok1041Screen.replace(/│ >[ \t]*│/u, '│ > 作業中の文字 │')), null) +}) From d9cb8d59f63d667763b7d195c1b61e9191041e25 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:52:21 +0900 Subject: [PATCH 53/72] =?UTF-8?q?=E8=A6=AAprobe=E3=81=AE=E5=AE=9FOS?= =?UTF-8?q?=E4=BF=AE=E7=90=86=E8=A8=BA=E6=96=AD=E3=81=A8=E6=96=B0=E9=85=8D?= =?UTF-8?q?=E5=B8=83=E5=80=99=E8=A3=9C=E3=81=AE=E7=8F=BE=E5=9C=A8=E5=9C=B0?= =?UTF-8?q?=E3=82=92=E6=96=87=E6=9B=B8=E3=81=B8=E5=8F=8D=E6=98=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plan_parent-native-delivery.md | 12 ++- rag/INDEX.md | 3 + rag/parent-delivery/cli-lifecycle-repairs.md | 12 +++ .../codex-probe-deadline-diagnosis.json | 96 +++++++++++++++++++ .../codex-probe-deadline-fingerprints.json | 30 ++++++ .../codex-probe-timer-repair-diagnosis.json | 54 +++++++++++ ...codex-probe-timer-repair-fingerprints.json | 35 +++++++ .../linux-grok-native-name-diagnosis.json | 45 +++++++++ rag/parent-delivery/receivers.md | 2 +- .../release-candidate-checkpoint.json | 64 ++++++++++--- 10 files changed, 333 insertions(+), 20 deletions(-) create mode 100644 rag/parent-delivery/codex-probe-deadline-diagnosis.json create mode 100644 rag/parent-delivery/codex-probe-deadline-fingerprints.json create mode 100644 rag/parent-delivery/codex-probe-timer-repair-diagnosis.json create mode 100644 rag/parent-delivery/codex-probe-timer-repair-fingerprints.json create mode 100644 rag/parent-delivery/linux-grok-native-name-diagnosis.json diff --git a/docs/plan_parent-native-delivery.md b/docs/plan_parent-native-delivery.md index f34cdd49..1ae8fef3 100644 --- a/docs/plan_parent-native-delivery.md +++ b/docs/plan_parent-native-delivery.md @@ -94,7 +94,7 @@ MCP requestと公式hookから実際の親を識別する。AIにthread IDやcon `parent_join`のreceiptは`schema: peertable.parent-join-result.v1`、`endpoint_id`、`state: binding_pending | receiving | verified | failed`、`harness`、`wait_process`、`error_code`を持つ。Cursor/Grokの結果返却後にhookで束縛する場合、最初のreceiptは`binding_pending`であり、成功やreadyと書かない。hookが返却結果の`endpoint_id`を実会話へ束縛した時点で`receiving`へ進み、購読とprobe配送を開始する。probeがその会話の受信経路を通った後に`verified`へ進める。`verified`を配送開始の条件にしてprobeを止める循環を作らない。進行は製品が続け、オーナーに手順の再実行を求めない。 -現行のClaude/Cursor/Grokは事前hookで実会話と入力digestを照合し、照合記録が30秒を超えた場合はendpoint作成前に`PARENT_BIND_TIMEOUT`で失敗する。Codexは公式MCPのthread metadataと実processを直接照合する。事前hookで実会話を照合できたjoinは、返却時点で`receiving`にできる。事後hookで初めて束縛するjoinだけが`binding_pending`を返す。旧watcherからの移行中は、束縛とreceiver準備が済んでも旧本文出力の停止が確認されるまで新watchとprobeを開始しない。束縛待ち・probe待ちはそれぞれ30秒を初期の明示期限とし、前者は`PARENT_BIND_TIMEOUT`、後者は`PARENT_PROBE_TIMEOUT`で失敗を残す。probe期限はnative receiverの準備確認後から計る。背景toolの登録前は、必要な登録入力を伴う`rearm_pending`として表示する。Windows Grokの実測ではモデルが登録toolを呼ぶまで数分かかり、join時点から計る旧実装の期限とは目的が異なった。遅れて確認された現在のprobeは、そのprobeの期限エラーだけを解除する。実機の開始時間を第1工程で測り、必要なら根拠を残して期限を調整する。無期限のpendingにしない。 +現行のClaude/Cursor/Grokは事前hookで実会話と入力digestを照合し、照合記録が30秒を超えた場合はendpoint作成前に`PARENT_BIND_TIMEOUT`で失敗する。Codexは公式MCPのthread metadataと実processを直接照合する。事前hookで実会話を照合できたjoinは、返却時点で`receiving`にできる。事後hookで初めて束縛するjoinだけが`binding_pending`を返す。旧watcherからの移行中は、束縛とreceiver準備が済んでも旧本文出力の停止が確認されるまで新watchとprobeを開始しない。束縛待ち・probe待ちはそれぞれ30秒を初期の明示期限とし、前者は`PARENT_BIND_TIMEOUT`、後者は`PARENT_PROBE_TIMEOUT`で失敗を残す。probe期限はnative receiverの準備確認後から計る。Codexの初回joinも公式receiver検証後、watcher起動前に期限を設定する。watcherはspoolのatomic更新を監視し、実期限へtimerを張る。期限到達はroomの心拍や公式RPCの完了を待たず、最新状態の成功・再武装・停止を照合してからfailedとhealthを記録する。背景toolの登録前は、必要な登録入力を伴う`rearm_pending`として表示する。Windows Grokの実測ではモデルが登録toolを呼ぶまで数分かかり、join時点から計る旧実装の期限とは目的が異なった。遅れて確認された現在のprobeは、そのprobeの期限エラーだけを解除する。実機の開始時間を第1工程で測り、必要なら根拠を残して期限を調整する。無期限のpendingにしない。 登録の疎通実績と現在の受信継続は別に記録する。runtimeの受信状態は`armed | rearm_pending | stopped | failed`とし、背景taskが終わって次の待機が登録されるまでを`rearm_pending`として診断へ出す。Cursor/Grokではnative背景toolの呼出しと結果を公式hookで相関し、task ID・受信process identity・endpointを保存する。receiptを生成しただけでは`armed`にしない。再登録までに届いた本文は配送記録へ保持し、次の待機が拾う。永久に再登録されない状態を健康と表示しない。 @@ -386,8 +386,12 @@ Codexの公式hook本文はXMLのtextとして記録される。[一次仕様の 試験controllerは製品sourceとは別のcommitと全実行moduleのSHA-256を記録し、実行fileを指定commitのGit blobへ照合する。Windowsの試験起動もPowerShell 7へ統一した。設定解除のJSON比較で空eventを除く補正を廃止し、Codexの信頼entryは専用project配下だけを解除する。自己停止は親終了からの実時間を記録し、30秒を超えた停止やrunnerによる止血を合格にしない。 -修理後の配布候補は[最新チェックポイント](../rag/parent-delivery/release-candidate-checkpoint.json)に固定した。macOS・Linux・Windows nativeの製品CIは全て合格し、131 fileのpackに新しい起動adapterが含まれ、試験・RAG・ツールの一時状態は含まれないことを確認した。packをmacOSの専用local prefixへnpmで導入し、版・runtime digestと診断readyを確認した。正式受入は修理後の再測定中で、旧候補の6件を新しいmanifestに数えない。registry版のglobal installと本番反映はまだ実施していない。 +配布候補のsource・版・digest・pack・導入状態・CIは[最新チェックポイント](../rag/parent-delivery/release-candidate-checkpoint.json)を正本とする。macOSの専用local prefixへのnpm導入はfixtureの確認であり、registry版のglobal installではない。fixtureは既存のglobalスキル配置を変更していないため、その配置診断はrequiredを保持する。公開後のglobal installで製品のスキル配置まで確認する。 -修理後のmacOS Claude/Codex通常CLIのaudienceは、[独立監査](../rag/parent-delivery/cli-formal-review-017bf2c.json)で共通pack、controllerのGit blob、room原文、実会話の本文と後続返答、spool、SQLite receiptを照合した。各3通が一致し、終了後の自動停止と設定解除も確認できたため、現在の受入manifestへ実測sourceを維持して追加した。短期scenarioの実作業file拡張子と記録反映の競合はcontroller側の修理中であり、失敗した項目を合格に数えない。長時間leaseと他OS・Desktop・IDEの受入は残る。 +旧候補のmacOS Claude/Codex通常CLIのaudienceは、[独立監査](../rag/parent-delivery/cli-formal-review-017bf2c.json)で共通pack、controllerのGit blob、room原文、実会話の本文と後続返答、spool、SQLite receiptを照合した。各3通の一致、終了後の自動停止と設定解除は試験当時の証拠として保存する。その後の製品修理により[旧sourceのsnapshot](../rag/parent-delivery/snapshots/017bf2c/product-acceptance.json)へ保存し、現行manifestには流用しない。 -その後、macOSの短命process終了時にOSが返す成功・空出力を形式異常と誤分類する製品欠陥を再現し、OS adapterを修理した。Linux Grokの公式更新先binary名を認識しない欠陥も原因を確認して修理した。この製品差分により`017bf2c`の正式受入2件は[snapshot](../rag/parent-delivery/snapshots/017bf2c/product-acceptance.json)へ保存し、現行manifestを0件へ戻した。旧sourceのWindows Claude/Codex・Linux Claude各3通も独立照合済みだが、新しい製品sourceへ流用しない。長時間leaseは開始処理の欠陥で成立せず、長時間試験の私物processの回収を確認した。原因・packのmode差・controller修理の現在地は[CLI修理記録](../rag/parent-delivery/cli-lifecycle-repairs.md)を参照する。新候補の固定、3 OS製品CI、実機受入、main着地、npm公開・registry導入・本番反映は残る。 +macOSの短命process終了時の成功・空出力の誤分類と、Linux Grokの公式更新先名の本人認識を修理した。さらにCodexの初回probeに期限が設定されない欠陥、期限判定がroom心拍を待って遅れる欠陥を実OS障害で確認し、初回armとwatcher自身のtimerを修理した。[CLI修理記録](../rag/parent-delivery/cli-lifecycle-repairs.md)に実測、未観測の境界、指紋、focused確認を保存する。これらの準備診断を正式配送の合格へ算入しない。 + +controllerはCursorの公式postToolUseが確定した入力全体を照合する。Windows Grokの公式罫線内の空promptは原画面fixtureで判定する。Claudeのhook無効化は同じ実会話を公式CLIの自己run設定でresumeし、共有設定を保持して無効化・復元の実因果を測定する。controllerのfocused合格だけで正式受入を埋めない。 + +現行manifestの正式受入は再測定中で、長時間leaseの合格もまだ無い。Linux Cursorは本人ログイン待ち、Desktop/IDEはJevの画面取得の解決または操作ツール変更の回答待ちである。新候補の3 OS製品CI、全必須面の実機受入、main着地、npm公開・registry導入・本番反映を順に完了する。 diff --git a/rag/INDEX.md b/rag/INDEX.md index 521438be..1c9aff9a 100644 --- a/rag/INDEX.md +++ b/rag/INDEX.md @@ -8,3 +8,6 @@ - [CLIの終了・設定解除・本人相関の修理](parent-delivery/cli-lifecycle-repairs.md) — Linuxの削除済みbinary、Windows Codex Job、公式設定解除とJSON構造、旧証跡の保存と再測定条件。 - [修理後候補のCLI正式証拠の独立監査](parent-delivery/cli-formal-review-017bf2c.json) — macOS Claude/Codexのaudience原文・実会話・receipt・controller・pack・終了回収の照合。 - [旧sourceのWindows・Linux CLI追加監査](parent-delivery/snapshots/017bf2c/additional-cli-independent-review.json) — 各3通の実会話・receipt照合と、Windows packのmode差。製品修理後の候補には算入しない。 +- [Linux Grok公式更新先名の実診断](parent-delivery/linux-grok-native-name-diagnosis.json) — 修理moduleの指紋と、実PID・開始identityによるGrok本人照合。準備診断であり正式配送受入には算入しない。 +- [Codex初回probeの実期限超過](parent-delivery/codex-probe-deadline-diagnosis.json) — 実app-serverの停止・自然timeout・room healthを照合。30秒期限への14.478秒の遅延を保持する修理診断。 +- [Codex probe timer修理後の実診断](parent-delivery/codex-probe-timer-repair-diagnosis.json) — 同じOS障害で実期限10ms後のroom failedを観測し、生timeline・PID・Git blobを独立照合。正式受入への算入は0件。 diff --git a/rag/parent-delivery/cli-lifecycle-repairs.md b/rag/parent-delivery/cli-lifecycle-repairs.md index 0b597178..3fd9d5f9 100644 --- a/rag/parent-delivery/cli-lifecycle-repairs.md +++ b/rag/parent-delivery/cli-lifecycle-repairs.md @@ -34,12 +34,24 @@ OS adapterは形式異常でも本人の消失を`ps`で確認し、確認でき Linux Grokは公式installerの実体名`grok-1.0.41-linux-x86_64`を旧判定が認識せず、事前hookが相関なしの`{}`を返し、実MCPのjoinが`PARENT_CALLER_UNBOUND`で失敗した。同じ公式実体の再起動で、実PID・開始identity・実pathと判定`null`を確認した。失敗当時の`/proc`原記録は取得できていない。版なしの公式実体名に加えて版を含む公式命名形式を認識し、未知のOS名は引き続き拒否する。正式Linux配送は新候補で再測定する。 +[修理moduleのLinux実診断](linux-grok-native-name-diagnosis.json)では、公式Grokの実PID・開始identityを維持して`processHarness`と`harnessProcess`が一致した。rootは修理moduleのSHAを現行commitのfileと独立照合した。他harnessと未知の実体名は本人へ割り当てない。診断用Node自身の祖先照会は`/proc`の権限異常で停止しており、その経路の成功は主張しない。この記録は`product_live:false`の準備診断であり、正式配送の合格へ算入しない。 + 修理前`017bf2c`の受入manifest・Mac2件の原証拠は[snapshot](snapshots/017bf2c/product-acceptance.json)へbyte単位で保存した。[追加3件の独立監査](snapshots/017bf2c/additional-cli-independent-review.json)はWindows Claude/Codex・Linux Claudeのroom原文、実会話、後続返答、spool、SQLite receipt、controllerのGit blobを照合した。各3通は一致した。Linux packの全131entryは凍結packと本文・modeとも一致した。Windows packの全file本文は一致したが27fileのmodeが異なり、圧縮byte列だけの差ではなかった。いずれも修理後の候補へ流用しない。 長時間leaseはMac Claudeの公式slotまで成立したが、controllerのpath関数とendpoint再join関数の名前衝突で開始処理が失敗し、24時間の検証は成立しなかった。Mac Cursor/GrokとWindows Cursor/Grokも準備段階で失敗し、Linux Cursorは未ログインで開始できなかった。私物processは回収済みで、長時間受信の合格は0件である。controllerの名前衝突、Grokの完了task全文reader入力、実応答・途中page・Codex task完了記録を待つ処理を修理し、正式試験と区別してfocused確認した。 Claude/Cursor/Grokの事前hookはharness・名前・入力digest・PID開始identityを照合する。照合記録の30秒期限切れは`PARENT_BIND_TIMEOUT`として本人不一致と区別し、endpoint作成前に失敗する。Codexは公式thread metadataと実processを直接照合する。 +## Codex初回probeの期限 + +初回Codex joinでは公式receiver検証後に作るspoolへprobe期限が設定されず、最初の公式app-server接続の失敗後も`receiving/armed`が残った。診断は専用watcherの実childをPID・開始identity・実binary・argvで限定してSIGSTOPし、製品の実15秒RPC timeoutと2秒close後の消失を観測した。時計・spool・RPC応答を改変していない。joinは成功した公式receiver検証後、watcher起動前に`armParentState`で30秒の期限を設定するよう修理し、実joinEndpointのfocused試験が合格した。 + +期限設定後の[実OS障害診断の原結果](codex-probe-deadline-diagnosis.json)では、30秒期限から14.478秒後に`PARENT_PROBE_TIMEOUT`とroomのfailed healthを観測した。[製品moduleとOS障害入口の指紋](codex-probe-deadline-fingerprints.json)をrootが実fileへ独立照合した。SSEのraw frameは採取していないため、心拍到着時刻との相関は直接観測として扱わない。コードでは期限判定が非同期queue処理後の`maintainReceiver`にあり、次の呼出しは25秒のSSE心拍まで待つ構造だった。これは30秒の期限合格ではなく、`product_live:false`の修理診断である。 + +watcherは所有spoolのdirectoryを監視し、atomic renameによる更新後の実期限へtimerを張る。期限到達時は同じatomic状態で再武装・成功・停止を照合してからfailedを保存し、healthを公開する。RPC待機とSSE心拍から期限を独立させた。心拍も新投稿も無い実SSE接続で、成功済みprobeの維持と後続の再武装・期限失敗を含む関連3試験が合格した。 + +[修理後の同じ実OS障害診断](codex-probe-timer-repair-diagnosis.json)では、実deadlineの10ms後にroomへfailed/PARENT_PROBE_TIMEOUTのhealthが記録され、最初のspool観測も332ms後に同じfailedとなった。rootは生timelineのGET members・spool・実親/child PIDを照合し、[指紋](codex-probe-timer-repair-fingerprints.json)の6実fileと製品3moduleのGit blobを照合した。停止したapp-serverは製品の実timeoutで消失し、自己fixture/serverの回収も確認した。修理診断であり、正式525件の受入には算入しない。 + 旧候補`43348b8`の6 audienceと18本文の独立監査は、元のsource・本文・判定を保った[snapshot](snapshots/43348b8/rag/parent-delivery/product-acceptance.json)へ保存した。現行manifestに算入しない。修理後の配布sourceと試験controllerをcommitへ固定し、Git blob・digest・版を照合して全必須面の正式試験を行う。main着地、npm公開、registry導入、本番反映はまだ行っていない。 Cursorのidle確認には[公式stop仕様](https://cursor.com/docs/hooks#stop)のcompletedと、同じconversation/generationを持つ専用observerを使う。公式資料とCLI実装の確認は、実機発火の合格と分ける。observerの起動時PID・開始identityを保存し、その本人の終了を照合する。親が先に終了した故障試験でも、停止時の所有証拠で私物receiverを再開・回収する。 diff --git a/rag/parent-delivery/codex-probe-deadline-diagnosis.json b/rag/parent-delivery/codex-probe-deadline-diagnosis.json new file mode 100644 index 00000000..ad4a72a1 --- /dev/null +++ b/rag/parent-delivery/codex-probe-deadline-diagnosis.json @@ -0,0 +1,96 @@ +{ + "diagnostic_only": true, + "product_live": false, + "source_commit": "4402c6cf761c668455527080c7ac831236b60256", + "parent_session": "01a0e9c0-73e3-7a73-bc79-0d4cc0c0d546", + "endpoint_id": "9d2d45d9-865a-44e3-94e2-a0ea32ac984b", + "owner": { + "pid": 60210, + "parent": 60192, + "started": "Tue Sep 29 05:41:55 2026", + "executable": "/Users/kite/.codex/packages/standalone/releases/0.155.1-aarch64-apple-darwin/bin/codex", + "command": "/Users/kite/.codex/packages/standalone/releases/0.155.1-aarch64-apple-darwin/bin/codex app-server --listen stdio://" + }, + "watcher": { + "pid": 60192, + "parent": 59546, + "started": "Tue Sep 29 05:41:54 2026", + "executable": "/opt/homebrew/Cellar/node/26.10.0_1/bin/node", + "command": "/opt/homebrew/Cellar/node/26.10.0_1/bin/node /private/tmp/peertable-release-4402c6c/prefix/node_modules/peertable/skill/scripts/parent-watch.mjs /Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/.lease-execution-017-b639/diagnostics/codex-fixed-deadline/native-fixtures/codex-b89017c2-de00-471e-8050-e8375a1fb00a/project fixture-76870bdd --deliver 9d2d45d9-865a-44e3-94e2-a0ea32ac984b" + }, + "parent": { + "pid": 59477, + "parent": 59460, + "started": "Tue Sep 29 05:41:38 2026", + "executable": "/Users/kite/.codex/packages/standalone/releases/0.155.1-aarch64-apple-darwin/bin/codex", + "command": "/Users/kite/.local/bin/codex -c mcp_servers.peertable_parent.env_vars=[\"PEERTABLE_TOKEN_SOURCE_FILE\"] -c check_for_update_on_startup=false -c mcp_servers.peertable_parent.env_vars=[\"PEERTABLE_TOKEN_SOURCE_FILE\",\"NODE_OPTIONS\",\"PEERTABLE_DIAGNOSTIC_SPAWN_CONFIG\"]" + }, + "receiver_check_hooks_request_at": "2026-09-28T20:41:54.797Z", + "first_probe_connection_suspended_at": "2026-09-28T20:41:55.124Z", + "probe_deadline": "2026-09-28T20:42:24.936Z", + "probe_failed_at": "2026-09-28T20:42:39.414Z", + "deadline_lag_ms": 14478, + "receiver_check_preceded_fault": true, + "first_probe_queue_add_issued": false, + "official_queue_accept_after_fault": [ + { + "at": "2026-09-28T20:42:14.203Z", + "rows": [ + { + "id": 2, + "result": { + "queuedSubmission": { + "id": "01a0e9c0-fd3a-7523-a9e7-6657a819fb77", + "input": [ + { + "type": "text", + "text": "[Peertable room=codex-probe-diagnostic-392cce82-e9c0-4710-9291-778be966c253 from=system to=\"fixture-76870bdd\" seq=2]\n本文: fixture-76870bdd が参加した\n[配送ID=59dd4a15-5b61-44b2-a6a0-62989219ed3b digest=6f84efaf9a29cc884b21021e6376ef897af4a66b16b6702e29a5bad67644a55d]", + "text_elements": [] + } + ], + "clientUserMessageId": "59dd4a15-5b61-44b2-a6a0-62989219ed3b" + } + } + } + ] + }, + { + "at": "2026-09-28T20:42:14.282Z", + "rows": [ + { + "id": 2, + "result": { + "queuedSubmission": { + "id": "01a0e9c0-fd8a-7611-973d-b5868382b6a5", + "input": [ + { + "type": "text", + "text": "[Peertable room=codex-probe-diagnostic-392cce82-e9c0-4710-9291-778be966c253 from=peertable to=\"fixture-76870bdd\" seq=039976fb4191af11c7f8b9d62045347818820c97f34cc39c0790935c6237f957]\n本文: 親番犬を張り直した。room の親宛と工程件数を追従する。\n[配送ID=deed2955-44aa-4f95-b2bc-7d824cd1235e digest=34a20a2937d09482c25381c75697858710ef23fe9a06b0da12b1ec96171fba65]", + "text_elements": [] + } + ], + "clientUserMessageId": "deed2955-44aa-4f95-b2bc-7d824cd1235e" + } + } + } + ] + } + ], + "typed_failure": "PARENT_PROBE_TIMEOUT", + "room_health": { + "state": "failed", + "endpoints": [ + { + "recipient": "fixture-76870bdd", + "endpoint_id": "9d2d45d9-865a-44e3-94e2-a0ea32ac984b", + "state": "failed", + "beat_at": "2026-09-28T20:42:39.324Z", + "detail": "{\"endpoint_id\":\"9d2d45d9-865a-44e3-94e2-a0ea32ac984b\",\"state\":\"failed\",\"error_code\":\"PARENT_PROBE_TIMEOUT\"}" + } + ] + }, + "fixture_closed": true, + "server_gone": true, + "contract_30_seconds_passed": false, + "heartbeat_timing_inference": "停止接続17秒SIGTERM→約2秒retry後queue受付→次failed healthが約25秒後。SSE rawframe自体は保存していないため到着相関はコードと実時刻からの推論。" +} \ No newline at end of file diff --git a/rag/parent-delivery/codex-probe-deadline-fingerprints.json b/rag/parent-delivery/codex-probe-deadline-fingerprints.json new file mode 100644 index 00000000..2132dbb9 --- /dev/null +++ b/rag/parent-delivery/codex-probe-deadline-fingerprints.json @@ -0,0 +1,30 @@ +{ + "diagnostic_only": true, + "product_live": false, + "source_commit": "4402c6cf761c668455527080c7ac831236b60256", + "runtime_digest": "b4d22888d6cadba929d8f2c4580dd3da0afa6627bb975c79ed41fea1f2995313", + "package_sha256": "7034de82a241da04f657aeb06bdd08c975539d96b645744a3a5b88cc0fdc7b9f", + "controller_commit": "bbee404aed6faf87d5a93dab655e9cebc1b0289a", + "files": [ + { + "file": "/private/tmp/peertable-release-4402c6c/prefix/node_modules/peertable/skill/scripts/parent-runtime.mjs", + "sha256": "fc1dbc30ce7f5c74a78e35ae3f58c33cf8d949b6d3aff9a85c94f14b248207af" + }, + { + "file": "/private/tmp/peertable-release-4402c6c/prefix/node_modules/peertable/skill/scripts/parent-watch.mjs", + "sha256": "1d21c6d36c9d1aba94ffba26d077551565ea2fc68c6b7ceb1bc4b2985b4928b5" + }, + { + "file": "/private/tmp/peertable-release-4402c6c/prefix/node_modules/peertable/skill/scripts/parent-receivers/codex.mjs", + "sha256": "6342cffb5727c0d1cd8aa7ae3ad49c704bf7408f18a7b01b9e7962cb02939c07" + }, + { + "file": "/Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/.lease-execution-017-b639/diagnostics/codex-fixed-deadline/observe-spawn.mjs", + "sha256": "fc9a28af0bf9b14be7097eaeb8ae376a1fb937ee50545396360569cbaf7123a4" + }, + { + "file": "/Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/.lease-execution-017-b639/diagnostics/codex-fixed-deadline/reproduce.mjs", + "sha256": "7f52b1c7b06c9d9ec28ce5cc13d3cb105ea984bdc611c8b3df40f2cd712998c5" + } + ] +} \ No newline at end of file diff --git a/rag/parent-delivery/codex-probe-timer-repair-diagnosis.json b/rag/parent-delivery/codex-probe-timer-repair-diagnosis.json new file mode 100644 index 00000000..7a1502e3 --- /dev/null +++ b/rag/parent-delivery/codex-probe-timer-repair-diagnosis.json @@ -0,0 +1,54 @@ +{ + "diagnostic_only": true, + "product_live": false, + "source_commit": "84de74a097469ef4abd43698635805696b1302ff", + "runtime_digest": "778b09708b12cdd808a1d7d8451aad5b34131ec9b5207f7bb50e4313f525ee7f", + "parent_session": "01a0e9c6-7238-7480-9c8c-702a9872344e", + "endpoint_id": "24947f53-a4a6-470b-a83e-ae960dc0ca83", + "parent": { + "pid": 70606, + "parent": 70592, + "started": "Tue Sep 29 05:48:11 2026", + "executable": "/Users/kite/.codex/packages/standalone/releases/0.155.1-aarch64-apple-darwin/bin/codex", + "command": "/Users/kite/.local/bin/codex -c mcp_servers.peertable_parent.env_vars=[\"PEERTABLE_TOKEN_SOURCE_FILE\"] -c check_for_update_on_startup=false -c mcp_servers.peertable_parent.env_vars=[\"PEERTABLE_TOKEN_SOURCE_FILE\",\"NODE_OPTIONS\",\"PEERTABLE_DIAGNOSTIC_SPAWN_CONFIG\"]" + }, + "watcher": { + "pid": 71697, + "parent": 70673, + "started": "Tue Sep 29 05:48:30 2026", + "executable": "/opt/homebrew/Cellar/node/26.10.0_1/bin/node", + "command": "/opt/homebrew/Cellar/node/26.10.0_1/bin/node /private/tmp/peertable-release-84de74a/prefix/node_modules/peertable/skill/scripts/parent-watch.mjs /Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/.lease-execution-017-b639/diagnostics/codex-timer-fixed/native-fixtures/codex-4eb96a62-281a-4325-9d86-1791109e426f/project fixture-d00ca855 --deliver 24947f53-a4a6-470b-a83e-ae960dc0ca83" + }, + "app_server": { + "pid": 71715, + "parent": 71697, + "started": "Tue Sep 29 05:48:31 2026", + "executable": "/Users/kite/.codex/packages/standalone/releases/0.155.1-aarch64-apple-darwin/bin/codex", + "command": "/Users/kite/.codex/packages/standalone/releases/0.155.1-aarch64-apple-darwin/bin/codex app-server --listen stdio://" + }, + "probe_deadline": "2026-09-28T20:49:00.953Z", + "first_failed_poll": { + "at": "2026-09-28T20:49:01.285Z", + "state": "failed", + "runtime": "failed", + "error_code": "PARENT_PROBE_TIMEOUT" + }, + "failed_health_beat_at": "2026-09-28T20:49:00.963Z", + "health_delay_ms": 10, + "state_at_35_seconds": "failed", + "terminal": { + "at": "2026-09-28T20:49:06.154Z", + "owner": { + "pid": 71715, + "parent": 71697, + "started": "Tue Sep 29 05:48:31 2026", + "executable": "/Users/kite/.codex/packages/standalone/releases/0.155.1-aarch64-apple-darwin/bin/codex", + "command": "/Users/kite/.codex/packages/standalone/releases/0.155.1-aarch64-apple-darwin/bin/codex app-server --listen stdio://" + }, + "elapsed_ms": 35011, + "still_same": false + }, + "fixture_closed": true, + "server_gone": true, + "errors": [] +} \ No newline at end of file diff --git a/rag/parent-delivery/codex-probe-timer-repair-fingerprints.json b/rag/parent-delivery/codex-probe-timer-repair-fingerprints.json new file mode 100644 index 00000000..bb5e04f0 --- /dev/null +++ b/rag/parent-delivery/codex-probe-timer-repair-fingerprints.json @@ -0,0 +1,35 @@ +{ + "at": "2026-09-28T20:48:10.840Z", + "diagnostic_only": true, + "product_live": false, + "source_commit": "84de74a097469ef4abd43698635805696b1302ff", + "runtime_digest": "778b09708b12cdd808a1d7d8451aad5b34131ec9b5207f7bb50e4313f525ee7f", + "package_sha256": "9f01372357e2b302f5fb241ab3e79fd78d7424ad13e047c4c214c5831eb198c8", + "controller_commit": "00cb736309f1db4f3cfd0f4253ec8ee914023a3a", + "files": [ + { + "file": "/private/tmp/peertable-release-84de74a/prefix/node_modules/peertable/skill/scripts/parent-runtime.mjs", + "sha256": "fc1dbc30ce7f5c74a78e35ae3f58c33cf8d949b6d3aff9a85c94f14b248207af" + }, + { + "file": "/private/tmp/peertable-release-84de74a/prefix/node_modules/peertable/skill/scripts/parent-watch.mjs", + "sha256": "374523f6a7127e4bded108911e2740fa34eb0b2216d0716e7a425aca6fc85183" + }, + { + "file": "/private/tmp/peertable-release-84de74a/prefix/node_modules/peertable/skill/scripts/parent-receivers/codex.mjs", + "sha256": "6342cffb5727c0d1cd8aa7ae3ad49c704bf7408f18a7b01b9e7962cb02939c07" + }, + { + "file": "/Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/.lease-execution-017-b639/diagnostics/codex-timer-fixed/observe-spawn.mjs", + "sha256": "fc9a28af0bf9b14be7097eaeb8ae376a1fb937ee50545396360569cbaf7123a4" + }, + { + "file": "/Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/.lease-execution-017-b639/diagnostics/codex-timer-fixed/reproduce.mjs", + "sha256": "faf07f7269bc56dee5ceffb05495fcf5b4069449c7a025ffbb86e82603f548a2" + }, + { + "file": "/Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/experiments/parent-product-acceptance/scenarios-fixtures.mjs", + "sha256": "3de7041fa45494568dffadd75157b61a7c9a5efdca7d33a3f763a45b9a652d08" + } + ] +} \ No newline at end of file diff --git a/rag/parent-delivery/linux-grok-native-name-diagnosis.json b/rag/parent-delivery/linux-grok-native-name-diagnosis.json new file mode 100644 index 00000000..5a015265 --- /dev/null +++ b/rag/parent-delivery/linux-grok-native-name-diagnosis.json @@ -0,0 +1,45 @@ +{ + "product_live": false, + "base_source": "017bf2c6342f573b23fa8b00c19ae34d0d291392", + "base_module_sha256": "89e92694bc95e8b08f8af7877612f533312b2b1b153fb979fd2cc12a60d6ffc9", + "overlay_module_sha256": "bf5d4539ebb68651e3f15c45f352f80f697452603843ac8eeef1e4199aa24174", + "overlay_source": "aiterm-unified-delivery worktree 13a506f (uncommitted skill/scripts/parent-platform.mjs)", + "overlay_note": "私物copy(/tmp/qa-017/overlay)へ1module overlay。install/global/共有設定には触れない", + "at": "2026-09-28T20:22:42.826Z", + "live_grok_pid": 256026, + "identity": { + "pid": 256026, + "parent": 256025, + "started": "0ab63c2c-462b-4839-98b2-057f138353d6:34864100", + "executable": "/home/kite/.grok/downloads/grok-1.0.41-linux-x86_64", + "command": "grok --always-approve " + }, + "exe_now": "/home/kite/.grok/downloads/grok-1.0.41-linux-x86_64", + "processHarness": "grok", + "harnessProcess_grok_owner": { + "pid": 256026, + "parent": 256025, + "started": "0ab63c2c-462b-4839-98b2-057f138353d6:34864100", + "executable": "/home/kite/.grok/downloads/grok-1.0.41-linux-x86_64", + "command": "grok --always-approve " + }, + "owner_same_pid_start": true, + "owner_executable": "/home/kite/.grok/downloads/grok-1.0.41-linux-x86_64", + "negatives": { + "other harness claude from grok pid": null, + "diagnostic node process (not grok descendant)": { + "typed_error": "PARENT_PROCESS_API_FAILED", + "message": "PID 255627: EACCES: permission denied, readlink '/proc/255627/exe'" + } + }, + "name_table": { + "grok-1.0.41-linux-x86_64": "grok", + "grok-linux-x86_64": "grok", + "grok-1.0.40-linux-x86_64": "grok", + "grok-9.9.9-freebsd-x86_64": null, + "grok-1.0-linux-x86_64": null, + "grok-1.0.41-linux-arm64": null, + "grok-x-linux-x86_64": null, + "grokbot-linux-x86_64": null + } +} diff --git a/rag/parent-delivery/receivers.md b/rag/parent-delivery/receivers.md index 01c59378..0f56f495 100644 --- a/rag/parent-delivery/receivers.md +++ b/rag/parent-delivery/receivers.md @@ -54,7 +54,7 @@ Cursor/Grokの6 CLIは旧snapshotで5件失敗、Windows Grokの無改造試験 ## 配布候補の確認 -最新の固定候補のsource、version、digest、製品CI、packと導入診断、公開gateの実行結果は[配布候補のチェックポイント](release-candidate-checkpoint.json)に保存する。3 OSの製品CIと正式tarballからの導入診断は成立したが、実機受入manifestの不足は残る。tarballを専用local prefixへnpmで導入した確認を、registry版のglobal installや本番反映として報告しない。 +最新の固定候補のsource、version、digest、製品CI、packと導入診断、公開gateの実行結果は[配布候補のチェックポイント](release-candidate-checkpoint.json)に保存する。過去候補の3 OS製品CIとtarball導入は確認済みだが、追加の製品修理後は新候補で再確認する。専用prefixの導入確認と共有globalスキルの配置診断を区別し、実機受入manifestの不足を保持する。tarballを専用local prefixへnpmで導入した確認を、registry版のglobal installや本番反映として報告しない。 Cursorの専用Desktop projectは正式tarballから構成した。OS権限の確認は成立したが、Jevの画面取得がwindow非公開・複数windowの相関・OS inventoryのtimeoutで停止し、初回メッセージは未送信である。専用ウィンドウとroom serverは停止した。モデルの利用上限やPeertableの配送不成立と同じ原因にしない。GUI試験の操作ツール変更はオーナーへの確認中であり、CLIの正式受入runnerは独立に準備している。 diff --git a/rag/parent-delivery/release-candidate-checkpoint.json b/rag/parent-delivery/release-candidate-checkpoint.json index 77f5e989..93ab8ae4 100644 --- a/rag/parent-delivery/release-candidate-checkpoint.json +++ b/rag/parent-delivery/release-candidate-checkpoint.json @@ -1,20 +1,18 @@ { "schema": "peertable.parent-release-checkpoint.v1", - "source_commit": "356f9c408bfb8a46327651130b513b21b1b2842a", - "runtime_digest": "1fd30ff226657e52a40416e2f9b2699631aecaef03307a4b34449cc0787647db", + "source_commit": "84de74a097469ef4abd43698635805696b1302ff", + "runtime_digest": "778b09708b12cdd808a1d7d8451aad5b34131ec9b5207f7bb50e4313f525ee7f", "package_version": "0.8.63", - "candidate_state": "repairing_before_source_freeze", - "source_frozen": false, + "candidate_state": "verifying_frozen_candidate", + "source_frozen": true, "previous_candidate_checkpoint": "rag/parent-delivery/snapshots/017bf2c/release-candidate-checkpoint.json", "product_ci": { - "status": "success", - "source_commit": "356f9c408bfb8a46327651130b513b21b1b2842a", - "head_commit": "590f5a8b65c0a00b7b0877495da2c6a65428101c", - "url": "https://github.com/kitepon/peertable/actions/runs/36478777216", + "status": "pending_push", + "source_commit": "84de74a097469ef4abd43698635805696b1302ff", "native_os": { - "darwin": "passed", - "linux": "passed", - "win32": "passed" + "darwin": "pending", + "linux": "pending", + "win32": "pending" } }, "focused_verification": { @@ -33,6 +31,18 @@ "scenario_transcript_observation": { "passed": 22, "failed": 0 + }, + "initial_codex_probe_deadline": { + "passed": 1, + "failed": 0 + }, + "source_runtime_and_independent_probe_timer": { + "passed": 3, + "failed": 0 + }, + "native_hook_fault_and_surface_registration": { + "passed": 36, + "failed": 0 } }, "documentation_tests": { @@ -41,9 +51,22 @@ "skipped": 0 }, "package": { - "status": "pending_new_product_source", - "previous_candidate_only": true, - "registry_install": false + "status": "fixture_installed", + "sha256": "9f01372357e2b302f5fb241ab3e79fd78d7424ad13e047c4c214c5831eb198c8", + "files": 133, + "dry_run_files": 133, + "installed_runtime_digest": "778b09708b12cdd808a1d7d8451aad5b34131ec9b5207f7bb50e4313f525ee7f", + "registry_install": false, + "skill_placement": { + "status": "required", + "reason": "専用prefixのfixture導入はignore-scriptsを使い、既存のglobalスキル配置を変更していない。registryのglobal installは公開後に行う。" + }, + "parent_connections": { + "codex": "registered", + "claude": "registered", + "cursor": "registered", + "grok": "registered" + } }, "product_acceptance": { "manifest": "rag/parent-delivery/product-acceptance.json", @@ -68,5 +91,16 @@ }, "published": false, "production_deployed": false, - "registry_install": false + "registry_install": false, + "previous_product_ci": { + "status": "success", + "source_commit": "356f9c408bfb8a46327651130b513b21b1b2842a", + "head_commit": "590f5a8b65c0a00b7b0877495da2c6a65428101c", + "url": "https://github.com/kitepon/peertable/actions/runs/36478777216", + "native_os": { + "darwin": "passed", + "linux": "passed", + "win32": "passed" + } + } } From 843ca886a870590d1b7c38e6be83d91533eb4d9f Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:53:17 +0900 Subject: [PATCH 54/72] =?UTF-8?q?Claude=E3=81=AE=E5=AE=9Fresume=E7=94=BB?= =?UTF-8?q?=E9=9D=A2=E3=82=92=E5=B0=82=E7=94=A8CID=E3=81=A8CLI=E6=9C=AC?= =?UTF-8?q?=E4=BA=BA=E3=81=A7=E7=85=A7=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scenarios-fixtures.mjs | 9 ++++++++- .../scenarios-fixtures.test.mjs | 13 ++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/experiments/parent-product-acceptance/scenarios-fixtures.mjs b/experiments/parent-product-acceptance/scenarios-fixtures.mjs index 8753ce63..569f4573 100644 --- a/experiments/parent-product-acceptance/scenarios-fixtures.mjs +++ b/experiments/parent-product-acceptance/scenarios-fixtures.mjs @@ -52,6 +52,13 @@ export function fixtureJoinInstruction({ harness, project, name }) { return common + registration + read + 'continuation_tokenがある間は同じ配送のparent_readを完了まで続けます。その結果の次wait_processも同じ完成入力で登録してください。' } +// 2.1.284の実resume画面はversionヘッダを表示しない。指定CID argvの新しい自己CLI本人まで照合する。 +export function claudeResumeReady({ screen, session, executable, owners, sameProcess }) { + if (!session || !/^[0-9a-f-]{36}$/u.test(session) || !/· Claude Pro/u.test(screen) || !/^[ \t]*❯[ \t\u00a0]*$/mu.test(screen)) return false + const command = new RegExp(`(?:^|\\s)--resume\\s+"?${session}"?(?:\\s|$)`, 'u') + return owners.some(owner => sameProcess(owner) && command.test(owner.command) && realpathSync(owner.executable) === realpathSync(executable)) +} + // 共有設定fileを書かず、公式--settingsを次の自己CLI runへ渡す。同じ実会話をresumeする。 export async function setClaudeSessionHookFault(fixture, disabled) { if (fixture.harness !== 'claude' || !fixture.session) fail('ACCEPTANCE_CLAUDE_SESSION_FAULT_UNBOUND', '既知の専用Claude会話が必要です') @@ -286,7 +293,7 @@ export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUr writeJson(fixture.identityArtifact, { at: new Date().toISOString(), project, harness, pty: fixture.pty, pane: fixture.paneOwner, native: fixture.nativeOwners, receiver: fixture.receiverOwners }) await fixture.trackOwnProcesses() await fixture.aiterm.send(fixture.pty, platform.shellCommand(process.execPath, [launcher, input])) - await until('通常HOMEの公式CLI起動', async () => { await fixture.trackOwnProcesses(); const action = adapter.startup(await fixture.aiterm.screen(fixture.pty)); if (action?.blocked) fail('ACCEPTANCE_FIXTURE_HARNESS_BLOCKED', action.blocked); if (action?.keys) { for (const key of action.keys) { await fixture.aiterm.key(fixture.pty, key); await sleep(500) } return false } return action?.ready }, 120000, 1500) + await until('通常HOMEの公式CLI起動', async () => { await fixture.trackOwnProcesses(); const view = await fixture.aiterm.screen(fixture.pty), action = resume && harness === 'claude' && claudeResumeReady({ screen: view, session: resume, executable: cli.executable, owners: fixture.nativeOwners, sameProcess: platform.sameProcess }) ? { ready: true } : adapter.startup(view); if (action?.blocked) fail('ACCEPTANCE_FIXTURE_HARNESS_BLOCKED', action.blocked); if (action?.keys) { for (const key of action.keys) { await fixture.aiterm.key(fixture.pty, key); await sleep(500) } return false } return action?.ready }, 120000, 1500) fixture.ready = true if (adapter.afterStartup) await adapter.afterStartup() return fixture diff --git a/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs b/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs index 45d6cfce..6751011e 100644 --- a/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs +++ b/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs @@ -7,7 +7,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { processIdentity, sameProcess } from '../../skill/scripts/parent-platform.mjs' -import { assertOnlyProjectTrustChanged, hookConfigurationSnapshot, waitOwnedFixtureExit, nativeHookObserverSource, appendGrokProductObserver, observeFixtureProcessIdentity, fixtureJoinInstruction, setClaudeSessionHookFault } from './scenarios-fixtures.mjs' +import { assertOnlyProjectTrustChanged, hookConfigurationSnapshot, waitOwnedFixtureExit, nativeHookObserverSource, appendGrokProductObserver, observeFixtureProcessIdentity, fixtureJoinInstruction, setClaudeSessionHookFault, claudeResumeReady } from './scenarios-fixtures.mjs' test('fixture終了は実child消失の後も製品自己停止と索引撤去を待つ', async t => { const child = spawn(process.execPath, ['-e', 'setTimeout(()=>process.exit(0),150)'], { stdio: 'ignore' }) @@ -133,3 +133,14 @@ test('Claude hook障害は同CIDの自己CLI設定だけを変え、元session assert.deepEqual(actions[6], { options: { resume: '自己会話' }, settings: original }) await assert.rejects(setClaudeSessionHookFault({ ...fixture, harness: 'cursor' }, true), { code: 'ACCEPTANCE_CLAUDE_SESSION_FAULT_UNBOUND' }) }) + + +test('Claudeのヘッダ無しresumeは実CID argv・存命CLI本人・公式空promptを照合する', () => { + const session = 'ac5f033b-462c-49c6-971b-d38a13ed5ad8', screen = ' ▝▜██████▀ Sonnet 5.5 · Claude Pro\n❯ \n', owner = { pid: 10, started: '専用開始', executable: process.execPath, command: `${process.execPath} --resume ${session} --settings /専用設定` } + const input = { screen, session, executable: process.execPath, owners: [owner], sameProcess: () => true } + assert.equal(claudeResumeReady(input), true) + assert.equal(claudeResumeReady({ ...input, sameProcess: () => false }), false) + assert.equal(claudeResumeReady({ ...input, session: '00000000-0000-0000-0000-000000000000' }), false) + assert.equal(claudeResumeReady({ ...input, screen: screen.replace('❯ ', '❯ 作業中') }), false) + assert.equal(claudeResumeReady({ ...input, screen: screen.replace('Claude Pro', '別製品') }), false) +}) From f9195386ca787e51999514273bdaff172d9ae6c1 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:55:49 +0900 Subject: [PATCH 55/72] =?UTF-8?q?=E7=8B=AC=E7=AB=8Bprobe=E6=9C=9F=E9=99=90?= =?UTF-8?q?=E4=BF=AE=E7=90=86=E5=BE=8C=E3=81=AE3=20OS=E8=A3=BD=E5=93=81CI?= =?UTF-8?q?=E5=90=88=E6=A0=BC=E3=82=92=E8=A8=98=E9=8C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../release-candidate-checkpoint.json | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/rag/parent-delivery/release-candidate-checkpoint.json b/rag/parent-delivery/release-candidate-checkpoint.json index 93ab8ae4..58cefc87 100644 --- a/rag/parent-delivery/release-candidate-checkpoint.json +++ b/rag/parent-delivery/release-candidate-checkpoint.json @@ -7,12 +7,14 @@ "source_frozen": true, "previous_candidate_checkpoint": "rag/parent-delivery/snapshots/017bf2c/release-candidate-checkpoint.json", "product_ci": { - "status": "pending_push", + "status": "success", "source_commit": "84de74a097469ef4abd43698635805696b1302ff", + "head_commit": "d9cb8d59f63d667763b7d195c1b61e9191041e25", + "url": "https://github.com/kitepon/peertable/actions/runs/36482248916", "native_os": { - "darwin": "pending", - "linux": "pending", - "win32": "pending" + "darwin": "passed", + "linux": "passed", + "win32": "passed" } }, "focused_verification": { @@ -43,6 +45,12 @@ "native_hook_fault_and_surface_registration": { "passed": 36, "failed": 0 + }, + "codex_real_os_probe_deadline": { + "product_live": false, + "health_delay_ms": 10, + "formal_acceptance_credit": 0, + "evidence": "rag/parent-delivery/codex-probe-timer-repair-diagnosis.json" } }, "documentation_tests": { From 257a7ea0ce84790cb9114b6d65eecec7c27e64ac Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:57:05 +0900 Subject: [PATCH 56/72] =?UTF-8?q?Claude=20resume=E3=81=AE=E5=AE=9Ffooter?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E3=82=92=E6=9C=AC=E4=BA=BA=E7=85=A7=E5=90=88?= =?UTF-8?q?=E3=81=97=E3=81=9F=E4=B8=8A=E3=81=A7=E6=89=B1=E3=81=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- experiments/parent-product-acceptance/scenarios-fixtures.mjs | 2 +- .../parent-product-acceptance/scenarios-fixtures.test.mjs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/experiments/parent-product-acceptance/scenarios-fixtures.mjs b/experiments/parent-product-acceptance/scenarios-fixtures.mjs index 569f4573..7d70eead 100644 --- a/experiments/parent-product-acceptance/scenarios-fixtures.mjs +++ b/experiments/parent-product-acceptance/scenarios-fixtures.mjs @@ -54,7 +54,7 @@ export function fixtureJoinInstruction({ harness, project, name }) { // 2.1.284の実resume画面はversionヘッダを表示しない。指定CID argvの新しい自己CLI本人まで照合する。 export function claudeResumeReady({ screen, session, executable, owners, sameProcess }) { - if (!session || !/^[0-9a-f-]{36}$/u.test(session) || !/· Claude Pro/u.test(screen) || !/^[ \t]*❯[ \t\u00a0]*$/mu.test(screen)) return false + if (!session || !/^[0-9a-f-]{36}$/u.test(session) || !(/· Claude Pro/u.test(screen) || /⏵⏵ auto mode on \(shift\+tab to cycle\)/u.test(screen)) || !/^[ \t]*❯[ \t\u00a0]*$/mu.test(screen)) return false const command = new RegExp(`(?:^|\\s)--resume\\s+"?${session}"?(?:\\s|$)`, 'u') return owners.some(owner => sameProcess(owner) && command.test(owner.command) && realpathSync(owner.executable) === realpathSync(executable)) } diff --git a/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs b/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs index 6751011e..f8dc08f7 100644 --- a/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs +++ b/experiments/parent-product-acceptance/scenarios-fixtures.test.mjs @@ -139,6 +139,7 @@ test('Claudeのヘッダ無しresumeは実CID argv・存命CLI本人・公式空 const session = 'ac5f033b-462c-49c6-971b-d38a13ed5ad8', screen = ' ▝▜██████▀ Sonnet 5.5 · Claude Pro\n❯ \n', owner = { pid: 10, started: '専用開始', executable: process.execPath, command: `${process.execPath} --resume ${session} --settings /専用設定` } const input = { screen, session, executable: process.execPath, owners: [owner], sameProcess: () => true } assert.equal(claudeResumeReady(input), true) + assert.equal(claudeResumeReady({ ...input, screen: '過去の自己会話\n❯ \n ⏵⏵ auto mode on (shift+tab to cycle) · ← for agents' }), true) assert.equal(claudeResumeReady({ ...input, sameProcess: () => false }), false) assert.equal(claudeResumeReady({ ...input, session: '00000000-0000-0000-0000-000000000000' }), false) assert.equal(claudeResumeReady({ ...input, screen: screen.replace('❯ ', '❯ 作業中') }), false) From bce891b2cbf3a77afa7ab738e19f965e7ac9808c Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:14:51 +0900 Subject: [PATCH 57/72] =?UTF-8?q?=E5=A4=B1=E6=95=97=E3=81=97=E3=81=9F?= =?UTF-8?q?=E8=A6=AA=E9=85=8D=E9=80=81=E7=A2=BA=E8=AA=8D=E3=82=92=E6=98=8E?= =?UTF-8?q?=E7=A4=BA=E5=86=8D=E7=99=BB=E9=8C=B2=E3=81=A7=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skill/scripts/parent-delivery.mjs | 7 ++-- skill/scripts/parent-runtime-probe.test.mjs | 38 +++++++++++++++++++++ skill/scripts/parent-runtime.mjs | 15 ++++---- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/skill/scripts/parent-delivery.mjs b/skill/scripts/parent-delivery.mjs index 1b86c58e..cb539840 100644 --- a/skill/scripts/parent-delivery.mjs +++ b/skill/scripts/parent-delivery.mjs @@ -17,6 +17,8 @@ export function armParentState(state) { state.runtime = 'armed' if (state.state !== 'verified' && state.probe_deadline == null) state.probe_deadline = Date.now() + 30000 } +// 明示再登録で新しいprobeが届いた場合だけ、既知の失敗probeを解決済みにする。 +export const failedParentRecords = state => state.records.filter(record => record.state === 'failed' && !(record.event.type === 'parent_probe' && record.resolved_by)) export function withParentLock(root, fn) { const owner = processIdentity(process.pid) @@ -88,7 +90,7 @@ export class ParentSpool { } async publishHealth() { const saved = this.read() - const state = saved.runtime === 'armed' && (saved.state !== 'verified' || saved.receipt_error || saved.migration?.status === 'evidence_missing' || saved.records.some(record => ['unknown', 'failed'].includes(record.state))) ? 'failed' : saved.runtime + const state = saved.runtime === 'armed' && (saved.state !== 'verified' || saved.receipt_error || saved.migration?.status === 'evidence_missing' || saved.records.some(record => record.state === 'unknown') || failedParentRecords(saved).length) ? 'failed' : saved.runtime try { await new RoomApi(saved, { credential: saved.credential }).request('bridges', { method: 'POST', body: { kind: 'parent_receiver', recipient: saved.name, endpoint_id: this.id, pid: process.pid, state, detail: JSON.stringify({ endpoint_id: this.id, state: saved.state, error_code: saved.error_code ?? saved.receipt_error?.code ?? null }) } }) this.update({ health_error: null }) @@ -194,8 +196,9 @@ export class ParentSpool { if (queued_submission_id) saved.queued_submission_id = queued_submission_id if (accepted_at) saved.accepted_at = accepted_at saved.receipt = this.receiptFor(state, saved, saved.state === 'submitted' ? 'delivered' : saved.state, saved.state === 'unknown' && outcome === 'submitted' ? saved.receipt?.reason ?? reason : reason) - if (saved.event.type === 'parent_probe' && saved.state === 'submitted') { + if (saved.event.type === 'parent_probe' && saved.state === 'submitted' && (!state.probe_id || saved.event.event_id === `probe:${state.probe_id}`)) { state.state = 'verified' + for (const previous of state.records) if (previous.event.type === 'parent_probe' && previous.state === 'failed') previous.resolved_by = saved.delivery_id if (state.error_code === 'PARENT_PROBE_TIMEOUT') { state.error_code = null if (state.runtime === 'failed') state.runtime = state.harness === 'codex' || sameProcess(state.waiter?.owner) ? 'armed' : 'rearm_pending' diff --git a/skill/scripts/parent-runtime-probe.test.mjs b/skill/scripts/parent-runtime-probe.test.mjs index 832efa2c..d3be85a5 100644 --- a/skill/scripts/parent-runtime-probe.test.mjs +++ b/skill/scripts/parent-runtime-probe.test.mjs @@ -6,6 +6,7 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { atomicJson, processIdentity } from './parent-platform.mjs' import { joinEndpoint, stopEndpoint, projectEndpoints } from './parent-runtime.mjs' +import { failedParentRecords } from './parent-delivery.mjs' test('初回Codex joinはwatcher起動前にprobe期限を開始し、背景登録待ちのCursorは開始しない', async t => { const project = realpathSync(mkdtempSync(join(tmpdir(), 'peertable-join-probe-'))) @@ -53,6 +54,43 @@ test('初回Codex joinはwatcher起動前にprobe期限を開始し、背景登 } else { assert.equal(state.runtime, 'rearm_pending') assert.equal(state.probe_deadline, undefined) + // 生存中watcherの明示rejoinでも、新probeだけを追加し、失敗本文とcursorを保つ。 + const failedProbe = state.records.find(record => record.event.type === 'parent_probe') + const failedDm = spool.saveEvent({ type: 'parent_dm', seq: 1, body: '配送失敗を保持する本文' }) + const unknownDm = spool.saveEvent({ type: 'parent_dm', seq: 2, body: '未確定を保持する本文' }) + spool.transact(saved => { + saved.state = 'failed'; saved.runtime = 'failed'; saved.error_code = 'PARENT_PROBE_TIMEOUT' + for (const record of saved.records) record.state = record.delivery_id === unknownDm.delivery_id ? 'unknown' : 'failed' + }) + const rejoined = await joinEndpoint(project, `fixture-${harness}`, { harness, conversation: `fixture-${harness}`, owner: processIdentity(process.pid) }) + const renewed = rejoined.read() + assert.equal(rejoined.id, spool.id) + assert.deepEqual(renewed.watcher, state.watcher) + assert.notEqual(renewed.probe_id, state.probe_id) + assert.equal(renewed.cursor, state.cursor) + assert.deepEqual(renewed.records.find(record => record.delivery_id === failedProbe.delivery_id).event, failedProbe.event) + assert.equal(renewed.records.find(record => record.delivery_id === failedDm.delivery_id).state, 'failed') + assert.equal(renewed.records.find(record => record.delivery_id === unknownDm.delivery_id).state, 'unknown') + const retry = spool.claim('parent_read') + assert.equal(retry.event.event_id, `probe:${renewed.probe_id}`) + assert.ok(failedParentRecords(spool.read()).some(record => record.delivery_id === failedProbe.delivery_id)) + spool.finish(retry) + const verified = spool.read() + assert.equal(verified.state, 'verified') + assert.equal(verified.records.find(record => record.delivery_id === failedProbe.delivery_id).state, 'failed') + assert.equal(verified.records.find(record => record.delivery_id === failedProbe.delivery_id).resolved_by, retry.delivery_id) + assert.deepEqual(failedParentRecords(verified).map(record => record.delivery_id), [failedDm.delivery_id]) + await joinEndpoint(project, `fixture-${harness}`, { harness, conversation: `fixture-${harness}`, owner: processIdentity(process.pid) }) + assert.equal(spool.read().probe_id, renewed.probe_id) + assert.equal(spool.read().records.length, verified.records.length) + spool.transact(saved => { + saved.state = 'failed'; saved.runtime = 'failed' + saved.records.find(record => record.delivery_id === retry.delivery_id).state = 'unknown' + }) + await joinEndpoint(project, `fixture-${harness}`, { harness, conversation: `fixture-${harness}`, owner: processIdentity(process.pid) }) + assert.equal(spool.read().probe_id, renewed.probe_id) + assert.equal(spool.read().records.length, verified.records.length) + assert.equal(spool.read().records.find(record => record.delivery_id === retry.delivery_id).state, 'unknown') } await stopEndpoint(spool) } diff --git a/skill/scripts/parent-runtime.mjs b/skill/scripts/parent-runtime.mjs index 5be00f41..314f867b 100644 --- a/skill/scripts/parent-runtime.mjs +++ b/skill/scripts/parent-runtime.mjs @@ -5,7 +5,7 @@ import { execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' import { setTimeout as delay } from 'node:timers/promises' import { randomUUID } from 'node:crypto' -import { ParentSpool, armParentState } from './parent-delivery.mjs' +import { ParentSpool, armParentState, failedParentRecords } from './parent-delivery.mjs' import { atomicJson, readJson, processIdentity, sameProcess, failure, posixQuote, psQuote } from './parent-platform.mjs' import { registerEndpoint, endpointsFor, forgetEndpoint } from './parent-caller.mjs' import { ownsParentConnection } from './parent-connect.mjs' @@ -90,10 +90,14 @@ export async function migrateLegacy(spool, api) { } spool.update({ cursor: legacy.last_seq, source_state: { ...legacy }, migration: { legacy_cursor: legacy.last_seq, insufficient_evidence_seqs: missingEvidence, status: missingEvidence.length ? 'evidence_missing' : 'verified', stopped_at: new Date().toISOString() } }) } -export async function startEndpoint(spool) { +export async function startEndpoint(spool, { renewProbe = false } = {}) { const state = spool.recover() if (!sameProcess(state.caller.owner)) throw failure('PARENT_SESSION_NOT_RUNNING') if (!Number.isSafeInteger(state.cursor)) throw failure('PARENT_CURSOR_MISSING') + const oldProbe = state.records.find(record => record.event.event_id === `probe:${state.probe_id}`) + const probe = !state.probe_id || renewProbe && oldProbe?.state === 'failed' ? randomUUID() : state.probe_id + spool.update({ probe_id: probe }) + spool.saveEvent({ schema: 'peertable.parent-watch-event.v1', type: 'parent_probe', event_id: `probe:${probe}`, room: state.room, from: 'peertable', to: state.name, body: `配送確認の符号は ${probe}。` }) const currentDigest = runtimeDigest() if (state.watcher && sameProcess(state.watcher) && state.runtime_digest === currentDigest) return if (state.watcher && sameProcess(state.watcher)) await stopOwnedProcess(state.watcher) @@ -115,9 +119,6 @@ export async function startEndpoint(spool) { if (!identity) throw failure('PARENT_WATCH_START_FAILED') saved.watcher = identity }) - const probe = spool.read().probe_id ?? randomUUID() - spool.update({ probe_id: probe }) - spool.saveEvent({ schema: 'peertable.parent-watch-event.v1', type: 'parent_probe', event_id: `probe:${probe}`, room: state.room, from: 'peertable', to: state.name, body: `配送確認の符号は ${probe}。` }) } export async function joinEndpoint(projectArg, name, caller, display = {}) { const project = realpathSync(projectArg), setup = setupFor(project) @@ -142,7 +143,7 @@ export async function joinEndpoint(projectArg, name, caller, display = {}) { actorEnvironment(project, name) // Codexはjoin前に公式queueと同期hookの準備を確認済み。初回も実probeの期限を開始する。 if (caller.harness === 'codex') spool.transact(armParentState) - await startEndpoint(spool) + await startEndpoint(spool, { renewProbe: true }) return spool } export async function parentRuntimeStatus(project, { restart = false } = {}) { @@ -161,7 +162,7 @@ export async function parentRuntimeStatus(project, { restart = false } = {}) { const live = sameProcess(state.caller.owner) && sameProcess(state.watcher) && state.runtime_digest === runtimeDigest() const armed = state.runtime === 'armed' && (state.harness === 'codex' || sameProcess(state.waiter?.owner)) const unknown = state.records.filter(record => record.state === 'unknown').map(record => record.delivery_id) - const failed = state.records.filter(record => record.state === 'failed').map(record => record.delivery_id) + const failed = failedParentRecords(state).map(record => record.delivery_id) const missingEvidence = state.migration?.status === 'evidence_missing' checks.push({ name: member.name, endpoint_id: spool.id, state: state.state, runtime: state.runtime, status: live && armed && state.state === 'verified' && !state.receipt_error && !state.health_error && !unknown.length && !failed.length && !missingEvidence ? 'ready' : 'failed', error_code: state.error_code ?? (!live ? 'PARENT_SESSION_NOT_RUNNING' : !armed ? 'PARENT_REARM_REQUIRED' : state.state !== 'verified' ? 'PARENT_PROBE_PENDING' : state.receipt_error ? 'PARENT_RECEIPT_FAILED' : state.health_error ? 'PARENT_HEALTH_REPORT_FAILED' : unknown.length ? 'PARENT_DELIVERY_UNKNOWN' : failed.length ? 'PARENT_DELIVERY_FAILED' : missingEvidence ? 'PARENT_LEGACY_EVIDENCE_MISSING' : null), unknown, failed, migration: state.migration }) From c97b431cbcb75e07a45cc17de6d688442b736af3 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:18:31 +0900 Subject: [PATCH 58/72] =?UTF-8?q?=E8=A6=AA=E3=81=AE=E5=86=8D=E7=99=BB?= =?UTF-8?q?=E9=8C=B2=E4=BF=AE=E7=90=86=E3=81=A8=E6=92=A4=E5=9B=9E=E5=80=99?= =?UTF-8?q?=E8=A3=9C=E3=81=AE=E5=AE=9F=E6=B8=AC=E3=82=92=E6=96=87=E6=9B=B8?= =?UTF-8?q?=E3=81=B8=E6=AE=8B=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plan_parent-native-delivery.md | 2 +- rag/INDEX.md | 1 + rag/parent-delivery/cli-lifecycle-repairs.md | 6 + .../codex-probe-rejoin-diagnosis.json | 115 ++++++++++++++++++ .../codex-probe-rejoin-fingerprints.json | 70 +++++++++++ .../release-candidate-checkpoint.json | 14 ++- .../84de74a/release-candidate-checkpoint.json | 114 +++++++++++++++++ skill/SKILL.md | 2 +- skill/templates/parent.md | 2 +- 9 files changed, 318 insertions(+), 8 deletions(-) create mode 100644 rag/parent-delivery/codex-probe-rejoin-diagnosis.json create mode 100644 rag/parent-delivery/codex-probe-rejoin-fingerprints.json create mode 100644 rag/parent-delivery/snapshots/84de74a/release-candidate-checkpoint.json diff --git a/docs/plan_parent-native-delivery.md b/docs/plan_parent-native-delivery.md index 1ae8fef3..671ae691 100644 --- a/docs/plan_parent-native-delivery.md +++ b/docs/plan_parent-native-delivery.md @@ -240,7 +240,7 @@ setup/resumeは登録済み親の配送runtimeも照合する。親の実セッ 旧watchからの移行は、保存cursorとroom receiptを照合する。`reason: parent_watch`で受付済みのmessageは再送しない。旧cursorより後に受付済みreceiptが残る場合も再送せず、新記録へ受付証拠を取り込む。旧cursor以前で本文到達の根拠が不足する項目は「旧方式の受付根拠が不足」と診断し、届かなかったと断定したり、一括再配送したりしない。旧cursor以後で未受付の対象を新方式へ保存する。対象外のseqは配送記録なしで追従cursorを進める。 -同じ実会話での再開は同じendpoint・配送記録を復旧する。別会話への新しい登録は新世代と束縛開始seqを記録し、旧endpointへ保存済みの本文・unknownを引き継がない。旧本文はroomの明示的な履歴取得で確認できるように保持する。保存cursorの欠落をresumeとみなして現在のheadへ飛ばすことはせず、初回登録の開始seqと復旧失敗を区別する。 +同じ実会話での再開は同じendpoint・配送記録を復旧する。 現在のprobeが既知のfailedなら、明示`parent_join`は生存watcherを維持して新しい符号のprobeを保存する。新probeが実受信経路で`submitted`となった後だけ、旧failed probeに`resolved_by`を記録し、healthと診断の未解決failureから除く。旧probeの本文・失敗状態・cursorは保持する。`unknown`のprobeを再送せず、DMのfailed/unknownをこの確認で解決しない。別会話への新しい登録は新世代と束縛開始seqを記録し、旧endpointへ保存済みの本文・unknownを引き継がない。旧本文はroomの明示的な履歴取得で確認できるように保持する。保存cursorの欠落をresumeとみなして現在のheadへ飛ばすことはせず、初回登録の開始seqと復旧失敗を区別する。 teardownは親の購読・hook待機・背景受信・共通watchを止めてから`.team`を片付ける。親のCodex/Claude/Grok/Cursor process自体は終了しない。他projectが使っているglobal hook/MCP登録は残す。停止に失敗したらstateを保持し、同じ入口から再実行できるようにする。 diff --git a/rag/INDEX.md b/rag/INDEX.md index 1c9aff9a..c36c8740 100644 --- a/rag/INDEX.md +++ b/rag/INDEX.md @@ -11,3 +11,4 @@ - [Linux Grok公式更新先名の実診断](parent-delivery/linux-grok-native-name-diagnosis.json) — 修理moduleの指紋と、実PID・開始identityによるGrok本人照合。準備診断であり正式配送受入には算入しない。 - [Codex初回probeの実期限超過](parent-delivery/codex-probe-deadline-diagnosis.json) — 実app-serverの停止・自然timeout・room healthを照合。30秒期限への14.478秒の遅延を保持する修理診断。 - [Codex probe timer修理後の実診断](parent-delivery/codex-probe-timer-repair-diagnosis.json) — 同じOS障害で実期限10ms後のroom failedを観測し、生timeline・PID・Git blobを独立照合。正式受入への算入は0件。 +- [Codex同会話での再登録失敗](parent-delivery/codex-probe-rejoin-diagnosis.json) — 自然timeout後の正規再登録でもfailed probeが不変となる実再現。開始時controller bytesと製品Git blobを照合し、正式合格へ算入しない。 diff --git a/rag/parent-delivery/cli-lifecycle-repairs.md b/rag/parent-delivery/cli-lifecycle-repairs.md index 3fd9d5f9..a3ce169c 100644 --- a/rag/parent-delivery/cli-lifecycle-repairs.md +++ b/rag/parent-delivery/cli-lifecycle-repairs.md @@ -55,3 +55,9 @@ watcherは所有spoolのdirectoryを監視し、atomic renameによる更新後 旧候補`43348b8`の6 audienceと18本文の独立監査は、元のsource・本文・判定を保った[snapshot](snapshots/43348b8/rag/parent-delivery/product-acceptance.json)へ保存した。現行manifestに算入しない。修理後の配布sourceと試験controllerをcommitへ固定し、Git blob・digest・版を照合して全必須面の正式試験を行う。main着地、npm公開、registry導入、本番反映はまだ行っていない。 Cursorのidle確認には[公式stop仕様](https://cursor.com/docs/hooks#stop)のcompletedと、同じconversation/generationを持つ専用observerを使う。公式資料とCLI実装の確認は、実機発火の合格と分ける。observerの起動時PID・開始identityを保存し、その本人の終了を照合する。親が先に終了した故障試験でも、停止時の所有証拠で私物receiverを再開・回収する。 + +## Codexの同会話での明示再登録 + +[実診断](codex-probe-rejoin-diagnosis.json)は初回app-serverを1回だけOS停止し、製品の自然timeoutと期限失敗の後に、同じ会話から正規`parent_join`を1回呼んだ。watcher本人とendpointは維持されたが、失敗probeのIDと記録が不変で、更新した30秒の期限でも再度failedになった。製品state・時計・RPC応答を変更していない。rootは[開始時bytesと製品Git blob](codex-probe-rejoin-fingerprints.json)を照合した。実装継続で変更されたcontrollerについては、診断開始時に保存したbytesを照合対象にした。 + +`startEndpoint`の生存watcherによる早期returnより前にprobeを保存し、明示joinでは現在の既知failed probeだけを新しい符号へ更新する。新probeの実受信完了後に旧failed synthetic probeへ`resolved_by`を追加する。原文・失敗状態・cursorを保持し、結果不明のprobeやDMの失敗を解決しない。配送29件、実join内部契約1件、watcher継続3件の関連focused試験が合格した。修正版の正規MCPによる再登録・新probe受信・後続DM・healthは再測定待ちであり、正式合格を増やしていない。 diff --git a/rag/parent-delivery/codex-probe-rejoin-diagnosis.json b/rag/parent-delivery/codex-probe-rejoin-diagnosis.json new file mode 100644 index 00000000..36a3909c --- /dev/null +++ b/rag/parent-delivery/codex-probe-rejoin-diagnosis.json @@ -0,0 +1,115 @@ +{ + "source_commit": "84de74a097469ef4abd43698635805696b1302ff", + "runtime_digest": "778b09708b12cdd808a1d7d8451aad5b34131ec9b5207f7bb50e4313f525ee7f", + "started_at": "2026-09-28T21:08:50.328Z", + "finished_at": "2026-09-28T21:11:07.698Z", + "CID": "01a0e9d9-5bde-7532-b060-ea66f0f62034", + "endpoint_id": "e293475e-606d-408d-8158-459462a6cee0", + "probe_id_before": "fc93fdd2-8fd1-4774-b547-7732574f3cd7", + "probe_id_after": "fc93fdd2-8fd1-4774-b547-7732574f3cd7", + "own_os_stops": 1, + "recovered_verified": false, + "fixture_closed": true, + "server_gone": true, + "errors": [], + "schema": "peertable.parent-rejoin-diagnosis.v1", + "product_live": false, + "formal_acceptance_credit": 0, + "independent_review": true, + "private_raw": "/Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/.lease-execution-017-b639/diagnostics/codex-probe-rejoin-raw", + "cause": "明示parent_join後も生存watcherの早期returnで新probeが作られず、既知failed probeを再利用する", + "natural_child_close": { + "at": "2026-09-28T21:09:25.077Z", + "signal": "SIGTERM", + "pid": 56640 + }, + "observations": [ + { + "at": "2026-09-28T21:09:43.251Z", + "state": "failed", + "runtime": "failed", + "probe_id": "fc93fdd2-8fd1-4774-b547-7732574f3cd7", + "probe_deadline": 1790629777876, + "error": "PARENT_PROBE_TIMEOUT", + "caller_use": "2", + "health": { + "recipient": "fixture-4fc08108", + "endpoint_id": "e293475e-606d-408d-8158-459462a6cee0", + "state": "failed", + "beat_at": "2026-09-28T21:09:37.882Z", + "detail": "{\"endpoint_id\":\"e293475e-606d-408d-8158-459462a6cee0\",\"state\":\"failed\",\"error_code\":\"PARENT_PROBE_TIMEOUT\"}" + }, + "records": [ + { + "delivery_id": "e2e9c2c5-5263-4615-931f-f4ac93fd74ba", + "state": "failed", + "digest": "c55ac47de796d2e3d8cf881d9708eda1786cccf00175850ff4623a1964c63e53" + } + ] + }, + { + "at": "2026-09-28T21:09:53.958Z", + "state": "receiving", + "runtime": "armed", + "probe_id": "fc93fdd2-8fd1-4774-b547-7732574f3cd7", + "probe_deadline": 1790629822965, + "error": null, + "caller_use": "3", + "health": { + "recipient": "fixture-4fc08108", + "endpoint_id": "e293475e-606d-408d-8158-459462a6cee0", + "state": "failed", + "beat_at": "2026-09-28T21:09:52.293Z", + "detail": "{\"endpoint_id\":\"e293475e-606d-408d-8158-459462a6cee0\",\"state\":\"failed\",\"error_code\":\"PARENT_PROBE_TIMEOUT\"}" + }, + "records": [ + { + "delivery_id": "e2e9c2c5-5263-4615-931f-f4ac93fd74ba", + "state": "failed", + "digest": "c55ac47de796d2e3d8cf881d9708eda1786cccf00175850ff4623a1964c63e53" + } + ] + }, + { + "at": "2026-09-28T21:10:23.129Z", + "state": "failed", + "runtime": "failed", + "probe_id": "fc93fdd2-8fd1-4774-b547-7732574f3cd7", + "probe_deadline": 1790629822965, + "error": "PARENT_PROBE_TIMEOUT", + "caller_use": "3", + "health": { + "recipient": "fixture-4fc08108", + "endpoint_id": "e293475e-606d-408d-8158-459462a6cee0", + "state": "failed", + "beat_at": "2026-09-28T21:10:22.969Z", + "detail": "{\"endpoint_id\":\"e293475e-606d-408d-8158-459462a6cee0\",\"state\":\"failed\",\"error_code\":\"PARENT_PROBE_TIMEOUT\"}" + }, + "records": [ + { + "delivery_id": "e2e9c2c5-5263-4615-931f-f4ac93fd74ba", + "state": "failed", + "digest": "c55ac47de796d2e3d8cf881d9708eda1786cccf00175850ff4623a1964c63e53" + } + ] + } + ], + "rejoin_native_tool_calls": [ + { + "at": "2026-09-28T21:09:52.975Z", + "conversation": "01a0e9d9-5bde-7532-b060-ea66f0f62034", + "turn": "01a0e9da-312d-7601-ac46-81cc5d2c62b1", + "tool_use_id": "exec-d42f0d64-e9a2-47a8-9087-32ab239dee2b", + "status": "completed", + "result": { + "schema": "peertable.parent-join-result.v1", + "endpoint_id": "e293475e-606d-408d-8158-459462a6cee0", + "state": "receiving", + "harness": "codex", + "wait_process": null, + "actor_environment": null, + "error_code": null + } + } + ] +} diff --git a/rag/parent-delivery/codex-probe-rejoin-fingerprints.json b/rag/parent-delivery/codex-probe-rejoin-fingerprints.json new file mode 100644 index 00000000..bf2c9a19 --- /dev/null +++ b/rag/parent-delivery/codex-probe-rejoin-fingerprints.json @@ -0,0 +1,70 @@ +{ + "product_live": false, + "formal_acceptance_credit": 0, + "product_source": "84de74a097469ef4abd43698635805696b1302ff", + "controller_base": "f306a0cb31c6df9ec6405d9154732f37c2b3a7d6", + "controller_uncommitted": "factory processEnvironment / pending target only; inherited diagnostic overrides unchanged", + "files": [ + { + "file": "/Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/.lease-execution-017-b639/diagnostics/codex-probe-rejoin-raw/reproduce.mjs", + "sha256": "676a5f5985530397beb58d514aed29937045da469ce8069c2d7929d8329b320c", + "audited_file": "/Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/.lease-execution-017-b639/diagnostics/codex-probe-rejoin-raw/reproduce.mjs", + "sha256_verified": true + }, + { + "file": "/Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/.lease-execution-017-b639/diagnostics/codex-probe-rejoin-raw/observe-spawn.mjs", + "sha256": "3ee23b3c0fd32c8e3d924670668dff01a2231032a4d35012ce7580d1be1f5748", + "audited_file": "/Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/.lease-execution-017-b639/diagnostics/codex-probe-rejoin-raw/observe-spawn.mjs", + "sha256_verified": true + }, + { + "file": "/Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/experiments/parent-product-acceptance/scenarios-fixtures.mjs", + "sha256": "9221eb6c4827bef5ac0616604f2e1de43f5ac788ea550dad513bfefa775ef881", + "audited_file": "/Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/.lease-execution-017-b639/diagnostics/codex-probe-rejoin-raw/factory-at-start.mjs.txt", + "sha256_verified": true + }, + { + "file": "/private/tmp/peertable-release-84de74a/prefix/node_modules/peertable/skill/scripts/parent-platform.mjs", + "sha256": "bf5d4539ebb68651e3f15c45f352f80f697452603843ac8eeef1e4199aa24174", + "audited_file": "/private/tmp/peertable-release-84de74a/prefix/node_modules/peertable/skill/scripts/parent-platform.mjs", + "sha256_verified": true, + "product_git_blob_verified": true + }, + { + "file": "/private/tmp/peertable-release-84de74a/prefix/node_modules/peertable/skill/scripts/parent-runtime.mjs", + "sha256": "fc1dbc30ce7f5c74a78e35ae3f58c33cf8d949b6d3aff9a85c94f14b248207af", + "audited_file": "/private/tmp/peertable-release-84de74a/prefix/node_modules/peertable/skill/scripts/parent-runtime.mjs", + "sha256_verified": true, + "product_git_blob_verified": true + }, + { + "file": "/private/tmp/peertable-release-84de74a/prefix/node_modules/peertable/skill/scripts/parent-watch.mjs", + "sha256": "374523f6a7127e4bded108911e2740fa34eb0b2216d0716e7a425aca6fc85183", + "audited_file": "/private/tmp/peertable-release-84de74a/prefix/node_modules/peertable/skill/scripts/parent-watch.mjs", + "sha256_verified": true, + "product_git_blob_verified": true + } + ], + "raw_files": [ + { + "file": "/Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/.lease-execution-017-b639/diagnostics/codex-probe-rejoin-raw/result.json", + "sha256": "7df72d98a024bdfa4b9c2971359fb0cc875abf612095468a066efcb256e2f6eb" + }, + { + "file": "/Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/.lease-execution-017-b639/diagnostics/codex-probe-rejoin-raw/diagnostic-summary.json", + "sha256": "8f8e1d3bebecd569ea862ba7b82a64c535f5da5358136e1b6e269b0f6dc9a445" + }, + { + "file": "/Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/.lease-execution-017-b639/diagnostics/codex-probe-rejoin-raw/rpc-os-raw.jsonl", + "sha256": "39f20004e698d1abf888c4f53cecb046f29a595af5e503088e9eab75fffae5a8" + }, + { + "file": "/Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/.lease-execution-017-b639/diagnostics/codex-probe-rejoin-raw/own-transcript.jsonl", + "sha256": "8508e9c4acd524ba69afd5dec750cf9cbf66366063508cef09662ccf3c954b91" + }, + { + "file": "/Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/.lease-execution-017-b639/diagnostics/codex-probe-rejoin-raw/factory-at-start.mjs.txt", + "sha256": "9221eb6c4827bef5ac0616604f2e1de43f5ac788ea550dad513bfefa775ef881" + } + ] +} diff --git a/rag/parent-delivery/release-candidate-checkpoint.json b/rag/parent-delivery/release-candidate-checkpoint.json index 58cefc87..e3c59bca 100644 --- a/rag/parent-delivery/release-candidate-checkpoint.json +++ b/rag/parent-delivery/release-candidate-checkpoint.json @@ -3,8 +3,8 @@ "source_commit": "84de74a097469ef4abd43698635805696b1302ff", "runtime_digest": "778b09708b12cdd808a1d7d8451aad5b34131ec9b5207f7bb50e4313f525ee7f", "package_version": "0.8.63", - "candidate_state": "verifying_frozen_candidate", - "source_frozen": true, + "candidate_state": "withdrawn_rejoin_defect", + "source_frozen": false, "previous_candidate_checkpoint": "rag/parent-delivery/snapshots/017bf2c/release-candidate-checkpoint.json", "product_ci": { "status": "success", @@ -87,9 +87,11 @@ "checkpoint_counts_as_live_acceptance": false }, "lease": { - "status": "no_active_long_lease", + "status": "withdrawal_requested", + "reported_live_before_withdrawal": 2, "passed": 0, - "previous_attempts": "failed_before_24h_wait" + "source_commit": "84de74a097469ef4abd43698635805696b1302ff", + "reason": "製品runtimeの再登録欠陥で候補撤回。原記録を保持し、所有試験だけを停止する。" }, "gui_observation": { "status": "blocked", @@ -110,5 +112,7 @@ "linux": "passed", "win32": "passed" } - } + }, + "repair_commit": "bce891b2cbf3a77afa7ab738e19f965e7ac9808c", + "withdrawal_evidence": "rag/parent-delivery/codex-probe-rejoin-diagnosis.json" } diff --git a/rag/parent-delivery/snapshots/84de74a/release-candidate-checkpoint.json b/rag/parent-delivery/snapshots/84de74a/release-candidate-checkpoint.json new file mode 100644 index 00000000..58cefc87 --- /dev/null +++ b/rag/parent-delivery/snapshots/84de74a/release-candidate-checkpoint.json @@ -0,0 +1,114 @@ +{ + "schema": "peertable.parent-release-checkpoint.v1", + "source_commit": "84de74a097469ef4abd43698635805696b1302ff", + "runtime_digest": "778b09708b12cdd808a1d7d8451aad5b34131ec9b5207f7bb50e4313f525ee7f", + "package_version": "0.8.63", + "candidate_state": "verifying_frozen_candidate", + "source_frozen": true, + "previous_candidate_checkpoint": "rag/parent-delivery/snapshots/017bf2c/release-candidate-checkpoint.json", + "product_ci": { + "status": "success", + "source_commit": "84de74a097469ef4abd43698635805696b1302ff", + "head_commit": "d9cb8d59f63d667763b7d195c1b61e9191041e25", + "url": "https://github.com/kitepon/peertable/actions/runs/36482248916", + "native_os": { + "darwin": "passed", + "linux": "passed", + "win32": "passed" + } + }, + "focused_verification": { + "darwin_process_exit": { + "passed": 2, + "failed": 0 + }, + "parent_delivery_and_harness_identity": { + "passed": 29, + "failed": 0 + }, + "native_fixture_and_lease_entry": { + "passed": 24, + "failed": 0 + }, + "scenario_transcript_observation": { + "passed": 22, + "failed": 0 + }, + "initial_codex_probe_deadline": { + "passed": 1, + "failed": 0 + }, + "source_runtime_and_independent_probe_timer": { + "passed": 3, + "failed": 0 + }, + "native_hook_fault_and_surface_registration": { + "passed": 36, + "failed": 0 + }, + "codex_real_os_probe_deadline": { + "product_live": false, + "health_delay_ms": 10, + "formal_acceptance_credit": 0, + "evidence": "rag/parent-delivery/codex-probe-timer-repair-diagnosis.json" + } + }, + "documentation_tests": { + "passed": 6, + "failed": 0, + "skipped": 0 + }, + "package": { + "status": "fixture_installed", + "sha256": "9f01372357e2b302f5fb241ab3e79fd78d7424ad13e047c4c214c5831eb198c8", + "files": 133, + "dry_run_files": 133, + "installed_runtime_digest": "778b09708b12cdd808a1d7d8451aad5b34131ec9b5207f7bb50e4313f525ee7f", + "registry_install": false, + "skill_placement": { + "status": "required", + "reason": "専用prefixのfixture導入はignore-scriptsを使い、既存のglobalスキル配置を変更していない。registryのglobal installは公開後に行う。" + }, + "parent_connections": { + "codex": "registered", + "claude": "registered", + "cursor": "registered", + "grok": "registered" + } + }, + "product_acceptance": { + "manifest": "rag/parent-delivery/product-acceptance.json", + "status": "failed", + "expected": 525, + "records": 0, + "errors": { + "PARENT_ACCEPTANCE_MISSING": 525 + }, + "checkpoint_counts_as_live_acceptance": false + }, + "lease": { + "status": "no_active_long_lease", + "passed": 0, + "previous_attempts": "failed_before_24h_wait" + }, + "gui_observation": { + "status": "blocked", + "tool": "jev-desktop", + "alternative_permission_pending": true, + "test_windows_closed": true + }, + "published": false, + "production_deployed": false, + "registry_install": false, + "previous_product_ci": { + "status": "success", + "source_commit": "356f9c408bfb8a46327651130b513b21b1b2842a", + "head_commit": "590f5a8b65c0a00b7b0877495da2c6a65428101c", + "url": "https://github.com/kitepon/peertable/actions/runs/36478777216", + "native_os": { + "darwin": "passed", + "linux": "passed", + "win32": "passed" + } + } +} diff --git a/skill/SKILL.md b/skill/SKILL.md index 09773586..1f09a2c3 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -72,7 +72,7 @@ seat-statusは`pty_observe`の状態・本人性・token hint・活動差分と ## resume -`peertable resume [--plan ] [--phase ] [--no-probe]` は既存`.team/`を基に生成物を更新し、登録された席の復帰と3 bridgeの更新を行い、fresh heartbeatとprobeのdelivered receiptを読み返す。生存席・roomログ・他projectは保つ。役割のない復帰対象はエラーで止まる。`--no-probe`は明示的に配達試験を省略する時だけ使う。親の監視は呼出し元に属するため、`parent_join`と耳疎通を現在の実会話で行う。Cursor/Grokは返却されたnative背景tool入力を登録し、`rearm_pending`から`armed`への進行を確認する。耳疎通probeの期限はnative receiverの準備確認後から計る。 +`peertable resume [--plan ] [--phase ] [--no-probe]` は既存`.team/`を基に生成物を更新し、登録された席の復帰と3 bridgeの更新を行い、fresh heartbeatとprobeのdelivered receiptを読み返す。生存席・roomログ・他projectは保つ。役割のない復帰対象はエラーで止まる。`--no-probe`は明示的に配達試験を省略する時だけ使う。親の監視は呼出し元に属するため、`parent_join`と耳疎通を現在の実会話で行う。Cursor/Grokは返却されたnative背景tool入力を登録し、`rearm_pending`から`armed`への進行を確認する。耳疎通probeの期限はnative receiverの準備確認後から計る。 同じ会話で確認が既知の失敗となった場合は、`parent_join`で新しい確認を開始できる。旧本文・失敗記録は残り、結果不明の配送は自動再送しない。 ## 席設定とmission diff --git a/skill/templates/parent.md b/skill/templates/parent.md index bfc0d14a..9d1be79a 100644 --- a/skill/templates/parent.md +++ b/skill/templates/parent.md @@ -48,7 +48,7 @@ Peertableの共通watchがHTTP/SSE、cursor、Lattice件数のquiet観測、取 - Cursor: 作業中の公式hookとnative背景Shellが同じclaimを共有する。`wait_process.native_tool`の完成済みinputをそのtoolへ渡す。生きたwaitがあるreceiptには新しいtoolが無い。背景完了後は`parent_read`の次receiptを登録する。 - Grok Build: 完成済み`run_terminal_command(background:true)`を登録する。完了済みの正確なtask IDを`get_command_or_subagent_output`でtimeoutなしに取得し、そこで示された配送IDを`parent_read`で回収して次receiptを登録する。 -長文は同じ配送ID・digest・継続tokenで最後まで回収する。`unknown`は自動再送しない。Cursor/Grokは外部作業がない受信turnでも受信維持toolを登録する。全toolを省略した場合は`rearm_pending`であり健康ではない。receipt作成だけで背景登録済みとしない。耳疎通が受信口を通って`verified`となり、現在runtimeが`armed`であることを診断する。`parent_leave`は受信登録だけを閉じ、親harnessを終了しない。 +長文は同じ配送ID・digest・継続tokenで最後まで回収する。`unknown`は自動再送しない。Cursor/Grokは外部作業がない受信turnでも受信維持toolを登録する。全toolを省略した場合は`rearm_pending`であり健康ではない。receipt作成だけで背景登録済みとしない。耳疎通が受信口を通って`verified`となり、現在runtimeが`armed`であることを診断する。同じ会話のprobeが既知の失敗となった場合は、`parent_join`で新しい確認を開始する。新確認が届くまで復旧済みとせず、旧失敗記録と本文を保持する。`unknown`は再送しない。`parent_leave`は受信登録だけを閉じ、親harnessを終了しない。 ## 試験結果の監査 From 95273f409aab353fe69923a9434061addf42d835 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:18:12 +0900 Subject: [PATCH 59/72] =?UTF-8?q?=E5=AE=9F=E6=A9=9Fprobe=E6=9C=9F=E9=99=90?= =?UTF-8?q?=E3=81=AEOS=E6=95=85=E9=9A=9C=E6=93=8D=E4=BD=9C=E3=81=A8?= =?UTF-8?q?=E5=8E=9F=E8=A8=98=E9=8C=B2=E3=82=92=E6=8E=A5=E7=B6=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scenarios-fixtures.mjs | 21 ++- .../scenarios-native.mjs | 58 ++++++- .../scenarios-queue-fault.mjs | 163 ++++++++++++++++++ .../scenarios-queue-fault.test.mjs | 81 +++++++++ 4 files changed, 315 insertions(+), 8 deletions(-) create mode 100644 experiments/parent-product-acceptance/scenarios-queue-fault.mjs create mode 100644 experiments/parent-product-acceptance/scenarios-queue-fault.test.mjs diff --git a/experiments/parent-product-acceptance/scenarios-fixtures.mjs b/experiments/parent-product-acceptance/scenarios-fixtures.mjs index 7d70eead..f437d1f4 100644 --- a/experiments/parent-product-acceptance/scenarios-fixtures.mjs +++ b/experiments/parent-product-acceptance/scenarios-fixtures.mjs @@ -52,6 +52,12 @@ export function fixtureJoinInstruction({ harness, project, name }) { return common + registration + read + 'continuation_tokenがある間は同じ配送のparent_readを完了まで続けます。その結果の次wait_processも同じ完成入力で登録してください。' } +// 公式env_vars指定へ自己observerだけを加え、既存のMCP環境参照を保持する。 +export function codexFixtureEnvironmentArgs(existing, processEnvironment = {}) { + if (existing !== undefined && (!Array.isArray(existing) || existing.some(value => typeof value !== 'string'))) fail('ACCEPTANCE_CODEX_ENV_VARS_SCHEMA', '公式configのMCP env_vars形式が不一致です') + return ['-c', `mcp_servers.peertable_parent.env_vars=${JSON.stringify([...new Set([...(existing ?? []), 'PEERTABLE_TOKEN_SOURCE_FILE', ...Object.keys(processEnvironment)])])}`, '-c', 'check_for_update_on_startup=false'] +} + // 2.1.284の実resume画面はversionヘッダを表示しない。指定CID argvの新しい自己CLI本人まで照合する。 export function claudeResumeReady({ screen, session, executable, owners, sameProcess }) { if (!session || !/^[0-9a-f-]{36}$/u.test(session) || !(/· Claude Pro/u.test(screen) || /⏵⏵ auto mode on \(shift\+tab to cycle\)/u.test(screen)) || !/^[ \t]*❯[ \t\u00a0]*$/mu.test(screen)) return false @@ -280,14 +286,16 @@ export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUr } fixture.launch = async ({ resume } = {}) => { const cli = adapter.resolveCli(), launcher = join(directory, 'launcher.mjs'), input = join(directory, 'launch.json') - const argv = harness === 'codex' ? ['-c', 'mcp_servers.peertable_parent.env_vars=["PEERTABLE_TOKEN_SOURCE_FILE"]', '-c', 'check_for_update_on_startup=false'] : harness === 'claude' ? [] : adapter.argv?.({ project, tokenFile }) ?? [] + const processEnvironment = fixture.processEnvironment ?? {} + const existingEnvironment = harness === 'codex' && Object.keys(processEnvironment).length ? (await userConfigRpc('config/read', { includeLayers: false })).config.mcp_servers?.peertable_parent?.env_vars : undefined + const argv = harness === 'codex' ? codexFixtureEnvironmentArgs(existingEnvironment, processEnvironment) : harness === 'claude' ? [] : adapter.argv?.({ project, tokenFile }) ?? [] if (fixture.sessionSettings && harness === 'claude') { const settings = join(project, 'session-settings.json'); writeJson(settings, fixture.sessionSettings); argv.push('--settings', settings) } if (model) argv.push(harness === 'codex' ? '-m' : '--model', model) if (resume) argv.unshift(...(harness === 'codex' ? ['resume', resume] : harness === 'claude' ? ['--resume', resume] : adapter.resumeArgs(resume))) const invocation = nativeInvocation(cli.executable, argv, { shellCommand: platform.shellCommand, interactive: true }) - writeJson(input, { executable: cli.executable, argv, invocation, project, tokenFile }) - // HOME・認証・model/provider設定はharnessの通常環境のまま。token参照だけを試験roomへ渡す。 - writeFileSync(launcher, `import {readFileSync} from 'node:fs';import {spawn} from 'node:child_process';const p=JSON.parse(readFileSync(process.argv[2],'utf8'));const child=spawn(p.invocation.executable,p.invocation.argv,{cwd:p.project,stdio:'inherit',env:{...process.env,PEERTABLE_TOKEN_SOURCE_FILE:p.tokenFile}});child.on('exit',(code)=>process.exit(code??1));\n`, { mode: 0o600 }) + writeJson(input, { executable: cli.executable, argv, invocation, project, tokenFile, processEnvironment }) + // HOME・認証は通常のまま。専用runのtoken参照とOS observerだけを公式env_varsへ渡す。 + writeFileSync(launcher, `import {readFileSync} from 'node:fs';import {spawn} from 'node:child_process';const p=JSON.parse(readFileSync(process.argv[2],'utf8'));const child=spawn(p.invocation.executable,p.invocation.argv,{cwd:p.project,stdio:'inherit',env:{...process.env,...p.processEnvironment,PEERTABLE_TOKEN_SOURCE_FILE:p.tokenFile}});child.on('exit',(code)=>process.exit(code??1));\n`, { mode: 0o600 }) if (!fixture.aiterm) fixture.aiterm = await openAiterm() if (!fixture.pty) fixture.pty = await fixture.aiterm.open(`pt-fixture-${randomUUID().slice(0, 8)}`, process.platform === 'win32' ? 'pwsh' : undefined) writeJson(fixture.identityArtifact, { at: new Date().toISOString(), project, harness, pty: fixture.pty, pane: fixture.paneOwner, native: fixture.nativeOwners, receiver: fixture.receiverOwners }) @@ -299,9 +307,10 @@ export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUr return fixture } fixture.submit = async text => { await fixture.aiterm.send(fixture.pty, text, false); await sleep(1500); await fixture.aiterm.key(fixture.pty, 'Enter') } - fixture.refreshTarget = async (previousEndpoint = null) => { + fixture.refreshTarget = async (previousEndpoint = null, { waitForVerified = true } = {}) => { const spool = await until('fixture parent_join', async () => { await fixture.trackOwnProcesses(); return projectEndpoints(project).find(item => item.id !== previousEndpoint && item.read().name === name && item.read().runtime !== 'stopped') }) - const joined = await until('fixture verified', async () => { await fixture.trackOwnProcesses(); const state = spool.read(); if (state.state === 'failed') fail(state.error_code, 'fixtureのjoinが失敗しました'); return state.state === 'verified' ? state : null }) + // probe障害の対象も製品が作ったcallerとspoolを読む。verifiedをfixtureで作り足さない。 + const joined = await until('fixture本人の束縛', async () => { await fixture.trackOwnProcesses(); const state = spool.read(); if (waitForVerified && state.state === 'failed') fail(state.error_code, 'fixtureのjoinが失敗しました'); return state.caller?.conversation && state.caller.owner?.started && platform.sameProcess(state.caller.owner) && (!waitForVerified || state.state === 'verified') ? state : null }) fixture.session = joined.caller.conversation; fixture.owner = joined.caller.owner const file = await until('fixture公式transcript', () => adapter.transcript(fixture.session)) const meta = { ...sourceMeta, harness, room, parent_session: fixture.session, parent_process: fixture.owner, endpoint_id: spool.id, harness_version: adapter.resolveCli().version } diff --git a/experiments/parent-product-acceptance/scenarios-native.mjs b/experiments/parent-product-acceptance/scenarios-native.mjs index 98f16366..bafeec11 100644 --- a/experiments/parent-product-acceptance/scenarios-native.mjs +++ b/experiments/parent-product-acceptance/scenarios-native.mjs @@ -1,5 +1,5 @@ // 公式CLI・公式queue・専用project hookで追加scenarioを動かす。fixtureの記録だけでは合格にしない。 -import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs' +import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, realpathSync } from 'node:fs' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { randomUUID } from 'node:crypto' @@ -9,6 +9,7 @@ import { createScenarioContext, completedNativeInput, completedNativeInputProof import { createNativeFixtureFactory, waitOwnedFixtureExit } from './scenarios-fixtures.mjs' import { sha256 } from './evidence.mjs' import { createBackgroundSurfaceAdapters, nativeInvocation } from './scenarios-surfaces.mjs' +import { installQueueProbeFault, queueProbeReceiverProof, watchQueueProbeState, queueProbeTimeoutProof, codexProbeJoinBoundary } from './scenarios-queue-fault.mjs' const fail = (code, message, detail) => { throw Object.assign(new Error(message), { code, detail }) } const rows = file => existsSync(file) ? readFileSync(file, 'utf8').split('\n').filter(Boolean).map(line => JSON.parse(line)) : [] @@ -633,7 +634,50 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { }, 120000, 100) return s.result(s, 'binding_timeout_typed_failed', 'harness_transcript', { tool_use_id: observed.use.id, error_code: observed.error.error_code, official_tool_output: observed.output, context_created_at: pending.context.value.created_at, released_at: releasedAt, elapsed_before_mcp_ms: Date.parse(releasedAt) - pending.context.value.created_at, observer_owner: pending.event.owner, endpoint_state_after_prejoin_failure: pending.target.spool.read().state, context_modified: false }) } - for (const action of ['probe_timeout_arm', 'probe_timeout_observe']) actions[action] = async (_, s) => sourceUnconfirmed(s.harness, '専用probe未消費を製品の実期限まで保持する操作', { source: join(s.pkg, 'skill/scripts/parent-watch.mjs'), parent_session: self(s).meta.parent_session }) + actions.probe_timeout_arm = async (_, s) => { + if (s.harness !== 'codex') sourceUnconfirmed(s.harness, '専用probe未消費を製品の実期限まで保持する操作', { source: join(s.pkg, 'skill/scripts/parent-watch.mjs'), parent_session: self(s).meta.parent_session }) + const runner = s.meta.runner + if (!/^[0-9a-f]{40}$/u.test(runner?.commit ?? '') || !runner.modules_sha256?.['scenarios-queue-fault.mjs'] || runner.modules_sha256['scenarios-queue-fault.mjs'] !== sha256(readFileSync(new URL('./scenarios-queue-fault.mjs', import.meta.url)))) fail('ACCEPTANCE_QUEUE_RUNNER_PROVENANCE_MISSING', 'OS故障moduleの正式controller commit/hashがありません') + const current = state(s), evidenceFile = s.artifactFor(s, 'probe-timeout-original'), cleanupFile = s.artifactFor(s, 'probe-timeout-cleanup') + current.probeDeadline = { evidenceFile, cleanupFile, runner } + const fixture = await factory.open({ harness: 'codex', initialJoin: false, prepare: async fixture => { + current.probeDeadline.fixture = fixture + ownedFixtures.add(fixture) + current.probeDeadline.fault = await installQueueProbeFault(fixture, { pkg: s.pkg, artifact: label => s.artifactFor(s, label) }) + } }) + const own = current.probeDeadline + await fixture.submit(`Peertable parent_joinをproject=${fixture.project} name=${fixture.name}で1回だけ呼び、結果を原文で報告してください。専用processの実probe期限を観測中です。自動再試行・設定変更はしないでください。`) + const pause = await s.until('専用watcher初回queue接続のOS停止', () => own.fault.pause(), 120000, 50) + own.pause = pause + const target = await fixture.refreshTarget(null, { waitForVerified: false }) + if (target.spool.id !== pause.endpoint_id || target.meta.parent_session !== pause.caller.conversation || target.meta.parent_process.pid !== pause.caller.owner.pid || target.meta.parent_process.started !== pause.caller.owner.started) fail('ACCEPTANCE_QUEUE_FAULT_CALLER_MISMATCH', '停止境界と実MCP相関のfixture本人が一致しません') + own.target = target + s.context.registerEndpoint('probe-timeout', target) + own.stateObserver = watchQueueProbeState({ spool: target.spool, artifact: s.artifactFor(s, 'probe-spool-os.jsonl') }) + const expectedSource = realpathSync(join(pause.caller.codex_home, 'hooks.json')) + own.receiver = queueProbeReceiverProof({ events: own.fault.events(), pause, expectedSource, expectedCommand: s.shellCommand(process.execPath, [join(s.pkg, 'skill/scripts/parent-hook.mjs'), 'codex']) }) + own.join = await s.until('同CIDの公式MCP join結果', () => codexProbeJoinBoundary({ file: target.file, session: target.meta.parent_session, project: fixture.project, name: fixture.name, endpointId: target.spool.id }), 15000, 50) + writeFileSync(evidenceFile, JSON.stringify({ runner, source: target.meta, pause, receiver: own.receiver, join: own.join }, null, 2)) + return s.result(s, 'own_probe_not_consumed', 'official_queue', { related_session: target.meta.parent_session, turn_id: own.join.turn_id, native_tool_use_id: own.join.native_tool_use_id, endpoint_id: target.spool.id, parent_owner: pause.caller.owner, watcher_owner: pause.watcher, child_owner: pause.owner, probe_deadline: pause.spool_before.probe_deadline, receiver_prepared_before_first_queue: own.receiver, original_artifact: evidenceFile, runner }) + } + actions.probe_timeout_observe = async (_, s) => { + const own = state(s).probeDeadline + if (!own?.pause || !own.stateObserver) fail('ACCEPTANCE_QUEUE_PROBE_FAULT_MISSING', '初回queue接続の自己OS停止証拠がありません') + let health + await s.until('実probe期限によるown spool/health failed', async () => { + if (own.stateObserver.error) throw own.stateObserver.error + const saved = own.target.spool.read() + health = await own.target.api('members') + const endpoint = health.bridges?.parent_receiver?.endpoints?.find(item => item.endpoint_id === own.target.spool.id) + return saved.state === 'failed' && saved.runtime === 'failed' && saved.error_code === 'PARENT_PROBE_TIMEOUT' && endpoint?.state === 'failed' + }, 45000, 50) + await s.until('35秒OS停止の自然終了/再開記録', () => own.fault.terminal(), 40000, 100) + const evidence = { runner: own.runner, source: own.target.meta, related_session: own.target.meta.parent_session, pause: own.pause, receiver: own.receiver, join: own.join, state_observations: own.stateObserver.rows, health, terminal: own.fault.terminal(), rpc_os_events: own.fault.events(), transcript_raw: readFileSync(own.target.file, 'utf8'), watch_log: readFileSync(join(own.target.spool.root, 'watch.log'), 'utf8') } + writeFileSync(own.evidenceFile, JSON.stringify(evidence, null, 2)) + // 正本に誤差契約が無い間は事実を保存してtyped未判定にする。独自秒数で期限合格を作らない。 + const proof = queueProbeTimeoutProof({ pause: own.pause, observations: own.stateObserver.rows, health }) + return s.result(s, 'probe_timeout_typed_failed', 'official_queue', { ...proof, related_session: own.target.meta.parent_session, turn_id: own.join.turn_id, original_artifact: own.evidenceFile, runner: own.runner }) + } actions.deadline_restore = async (_, s) => { const pending = state(s).deadline if (pending) for (const release of pending.releases) writeFileSync(release, '{}') @@ -691,6 +735,7 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { const errors = [] try { const current = saved.get(s.runId) + if (current?.probeDeadline) { current.probeDeadline.stateObserver?.close(); await current.probeDeadline.fault?.close() } for (const release of current?.deadline?.releases ?? []) writeFileSync(release, '{}') if (current?.work) { writeFileSync(current.work.release, '{}'); if (current.work.owner) await waitOwnedFixtureExit({ readEndpoints: () => [], knownOwners: [current.work.owner], indexExists: () => false, sameProcess: s.sameProcess, timeout: 30000 }) } if (current?.final) { writeFileSync(current.final.release, '{}'); if (current.final.monitor) { current.final.monitor.canceled = true; await current.final.monitor.promise } } @@ -709,6 +754,15 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { } } catch (error) { errors.push(error) } try { await factory.close() } catch (error) { errors.push(error) } + const probe = saved.get(s.runId)?.probeDeadline + if (probe) { + try { + const owners = [probe.pause?.caller.owner, probe.pause?.watcher, probe.pause?.owner].filter(Boolean), live = owners.filter(owner => s.sameProcess(owner)) + const cleanup = { at: new Date().toISOString(), runner: probe.runner, related_session: probe.target?.meta.parent_session, endpoint_id: probe.pause?.endpoint_id, known_owners: owners, live_owners: live, fixture_closed: probe.fixture?.closed === true, product_state: probe.target?.spool.read() } + writeFileSync(probe.cleanupFile, JSON.stringify(cleanup, null, 2)) + if (live.length || !cleanup.fixture_closed) fail('ACCEPTANCE_QUEUE_FAULT_CLEANUP_INCOMPLETE', '自己probe fixtureの親/受信/接続processが残っています', cleanup) + } catch (error) { errors.push(error) } + } if (errors.length) throw Object.assign(new AggregateError(errors, '追加scenarioの所有資産を後片付けできません'), { code: 'ACCEPTANCE_NATIVE_CLEANUP_FAILED' }) } } } diff --git a/experiments/parent-product-acceptance/scenarios-queue-fault.mjs b/experiments/parent-product-acceptance/scenarios-queue-fault.mjs new file mode 100644 index 00000000..289a2e75 --- /dev/null +++ b/experiments/parent-product-acceptance/scenarios-queue-fault.mjs @@ -0,0 +1,163 @@ +// 公式receiver準備と、専用watcherが生成した最初のqueue接続を原RPC/OS identityで照合する。 +import { readFileSync, appendFileSync, writeFileSync, existsSync, realpathSync, watch } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { syncBuiltinESMExports } from 'node:module' +import cp from 'node:child_process' + +const fail = (code, message) => { throw Object.assign(new Error(message), { code }) } +const sameIdentity = (a, b) => a && b && a.pid === b.pid && a.started === b.started + +// Codex公式itemのMCP結果を読む。execの自由文出力やモデルの報告を結果へ代用しない。 +export function codexProbeJoinBoundary({ file, session, project, name, endpointId, after = -1 }) { + const lines = readFileSync(file, 'utf8').split('\n'); lines.pop() + let actualSession = null + for (const [order, line] of lines.entries()) { + let row + try { row = JSON.parse(line) } catch { fail('ACCEPTANCE_TRANSCRIPT_CORRUPT', '自己Codexの確定transcript行がJSONではありません') } + if (row.type === 'session_meta') actualSession = row.payload.id + const payload = row.payload, item = payload?.item, value = item?.result?.structuredContent + if (order > after && actualSession === session && row.type === 'event_msg' && payload.type === 'item_completed' && payload.thread_id === session && payload.turn_id && item?.type === 'McpToolCall' && item.server === 'peertable_parent' && item.tool === 'parent_join' && item.status === 'completed' && item.arguments?.project === project && item.arguments?.name === name && value?.schema === 'peertable.parent-join-result.v1' && value.endpoint_id === endpointId && ['receiving', 'verified'].includes(value.state)) return { order, timestamp: row.timestamp, session, turn_id: payload.turn_id, native_tool_use_id: item.id, raw: row, result: value } + } + return null +} + +// chunk分割を保持した原logから、改行で確定した公式JSON-RPCだけを復元する。 +export function queueFaultRpcRows(events) { + const buffers = new Map(), rows = [] + for (const event of events) { + if (!['rpc_write_raw', 'rpc_read_raw'].includes(event.kind)) continue + const key = `${event.pid}:${event.child_pid}:${event.kind}`, text = (buffers.get(key) ?? '') + event.raw + const lines = text.split('\n'); buffers.set(key, lines.pop()) + for (const line of lines) { + let row + try { row = JSON.parse(line) } catch { fail('ACCEPTANCE_QUEUE_FAULT_RPC_CORRUPT', '公式RPCの確定行がJSONではありません') } + rows.push({ ...event, row }) + } + } + return rows +} + +export function queueProbeReceiverProof({ events, pause, expectedCommand, expectedSource }) { + const rows = queueFaultRpcRows(events), connections = events.filter(event => event.kind === 'official_connection_spawn' && event.pid !== pause.watcher.pid && event.at < pause.paused_at && event.executable === pause.executable && JSON.stringify(event.spawn_argv) === JSON.stringify(['app-server', '--listen', 'stdio://'])) + for (const connection of connections) { + const own = rows.filter(row => row.child_pid === connection.child_pid && row.pid === connection.pid) + const call = method => own.find(row => row.kind === 'rpc_write_raw' && row.row.method === method) + const result = request => request && own.find(row => row.kind === 'rpc_read_raw' && row.row.id === request.row.id && row.row.result && row.at < pause.paused_at) + const initialize = call('initialize'), thread = call('thread/read'), queue = call('thread/queue/list'), hooks = call('hooks/list') + const hookResult = result(hooks), entries = hookResult?.row.result.data?.flatMap(group => group.hooks ?? []) ?? [] + const owned = entries.filter(hook => hook.command === expectedCommand && hook.sourcePath === expectedSource && hook.handlerType === 'command' && hook.async === false) + const actualThread = result(thread)?.row.result.thread + const subagent = actualThread?.source?.subAgent + if (!result(initialize) || !thread || thread.row.params.threadId !== pause.caller.conversation || thread.row.params.includeTurns !== false || actualThread?.id !== pause.caller.conversation || subagent && typeof subagent === 'object' && 'thread_spawn' in subagent || !queue || queue.row.params.threadId !== pause.caller.conversation || queue.row.params.limit !== 1 || !result(queue) || !hooks?.row.params.cwds.includes(pause.project) || owned.length !== 2 || !['postToolUse', 'stop'].every(event => owned.some(hook => hook.eventName === event && hook.enabled === true && hook.trustStatus === 'trusted'))) continue + const watcherRequests = rows.filter(row => row.pid === pause.watcher.pid && row.child_pid === pause.owner.pid && row.kind === 'rpc_write_raw') + if (!watcherRequests.some(row => row.row.method === 'initialize') || watcherRequests.some(row => row.row.method === 'thread/queue/add')) fail('ACCEPTANCE_QUEUE_PROBE_FAULT_STAGE', '初回probe受付前の公式接続停止ではありません') + return { connection, initialize: result(initialize), thread: result(thread), queue: result(queue), hooks: hookResult, owned_hooks: owned, stopped_before_queue_add: true } + } + fail('ACCEPTANCE_QUEUE_RECEIVER_CHECK_MISSING', '故障前に公式receiver本人/queue/hooks検証が成功した原RPCがありません') +} + +// atomic renameでも同じspoolを追う。読取のみで実期限/failedの初回観測時刻を保存する。 +export function watchQueueProbeState({ spool, artifact }) { + const observer = { rows: [], error: null, closed: false } + const capture = trigger => { + try { + const raw = readFileSync(spool.file), state = JSON.parse(raw) + const row = { at: new Date().toISOString(), trigger, raw: raw.toString('utf8'), state } + observer.rows.push(row) + appendFileSync(artifact, JSON.stringify(row) + '\n', { mode: 0o600 }) + } catch (error) { observer.error = error } + } + const watcher = watch(dirname(spool.file), (_, filename) => { if (!filename || String(filename) === 'spool.json') capture('fs.watch') }) + watcher.on('error', error => { observer.error = error }) + capture('start') + observer.close = () => { if (!observer.closed) { observer.closed = true; watcher.close() } } + return observer +} + +// 製品期限と観測誤差の契約を分ける。未提供の誤差値を推測して合格へ丸めない。 +export function queueProbeTimeoutProof({ pause, observations, health, maxObservationLagMs }) { + const deadline = pause.spool_before.probe_deadline + if (!Number.isFinite(deadline)) fail('ACCEPTANCE_QUEUE_PROBE_DEADLINE_MISSING', '実probe_deadlineがありません') + const failed = observations.find(row => row.state.endpoint_id === pause.endpoint_id && row.state.state === 'failed' && row.state.runtime === 'failed' && row.state.error_code === 'PARENT_PROBE_TIMEOUT') + const endpoint = health.bridges?.parent_receiver?.endpoints?.find(item => item.endpoint_id === pause.endpoint_id) + let detail + try { detail = typeof endpoint?.detail === 'string' ? JSON.parse(endpoint.detail) : endpoint?.detail } catch { fail('ACCEPTANCE_QUEUE_HEALTH_DETAIL_CORRUPT', '公式GETmembersのdetailがJSONではありません') } + if (!failed || endpoint?.state !== 'failed' || detail?.endpoint_id !== pause.endpoint_id || detail.state !== 'failed' || detail.error_code !== 'PARENT_PROBE_TIMEOUT') fail('ACCEPTANCE_QUEUE_PROBE_TIMEOUT_NOT_OBSERVED', 'own endpointの実spoolとGETmembersに同じ期限failureがありません') + const observationLag = Date.parse(failed.at) - deadline, healthLag = Date.parse(endpoint.beat_at) - deadline + const measurement = { probe_deadline: deadline, first_failed_at: failed.at, failed_observation_lag_ms: observationLag, failed_health_beat_at: endpoint.beat_at, failed_health_lag_ms: healthLag, failed_state: failed.state, health_endpoint: endpoint } + if (!Number.isFinite(maxObservationLagMs) || maxObservationLagMs < 0) throw Object.assign(new Error('30秒期限のOS/観測誤差契約が未指定です'), { code: 'ACCEPTANCE_QUEUE_DEADLINE_OBSERVATION_CONTRACT_MISSING', detail: measurement }) + if (!Number.isFinite(observationLag) || !Number.isFinite(healthLag) || observationLag < 0 || healthLag < 0 || observationLag > maxObservationLagMs || healthLag > maxObservationLagMs) throw Object.assign(new Error('実probe failureは期限と観測誤差契約の範囲を満たしません'), { code: 'ACCEPTANCE_QUEUE_PROBE_TIMEOUT_LATE', detail: measurement }) + return { ...measurement, max_observation_lag_ms: maxObservationLagMs } +} + +export async function installQueueProbeFault(fixture, { pkg, artifact, holdMs = 35000 }) { + if (fixture.harness !== 'codex' || fixture.pty) fail('ACCEPTANCE_QUEUE_FAULT_PREPARATION_ORDER', '専用Codex起動前だけにOS故障observerを準備します') + if (process.platform === 'win32') fail('ACCEPTANCE_QUEUE_FAULT_OS_API_UNCONFIRMED', 'Windowsの所有childを停止/再開する公式OS APIはまだ実測していません') + const configFile = artifact('probe-fault-config'), logFile = artifact('probe-rpc-os.jsonl'), pauseFile = artifact('probe-suspended'), terminalFile = artifact('probe-terminal'), disarmedFile = artifact('probe-fault-disarmed') + const config = { pkg, project: fixture.project, hold_ms: holdMs, log_file: logFile, pause_file: pauseFile, terminal_file: terminalFile, disarmed_file: disarmedFile } + writeFileSync(configFile, JSON.stringify(config, null, 2), { mode: 0o600 }) + fixture.processEnvironment = { NODE_OPTIONS: [process.env.NODE_OPTIONS, `--import=${pathToFileURL(fileURLToPath(import.meta.url)).href}`].filter(Boolean).join(' '), PEERTABLE_ACCEPTANCE_QUEUE_PROBE: configFile } + const platform = await import(pathToFileURL(join(pkg, 'skill/scripts/parent-platform.mjs')).href) + return { + config_file: configFile, log_file: logFile, pause_file: pauseFile, terminal_file: terminalFile, + events: () => existsSync(logFile) ? readFileSync(logFile, 'utf8').split('\n').filter(Boolean).map(JSON.parse) : [], + pause: () => existsSync(pauseFile) ? JSON.parse(readFileSync(pauseFile, 'utf8')) : null, + terminal: () => existsSync(terminalFile) ? JSON.parse(readFileSync(terminalFile, 'utf8')) : null, + async close() { + // 自分のobserver制御だけを解除する。製品の時計/state/RPCは変更しない。 + writeFileSync(disarmedFile, '{}', { mode: 0o600 }) + const paused = this.pause() + if (paused && platform.sameProcess(paused.owner)) { + process.kill(paused.owner.pid, 'SIGCONT') + appendFileSync(logFile, JSON.stringify({ at: new Date().toISOString(), kind: 'finalize_own_sigcont', owner: paused.owner }) + '\n') + } + delete fixture.processEnvironment + }, + } +} + +// --importは自己projectの子だけに継承する。spawn/RPCの引数・戻り値・callbackは一切変更しない。 +const configPath = process.env.PEERTABLE_ACCEPTANCE_QUEUE_PROBE +if (configPath && existsSync(configPath)) { + const config = JSON.parse(readFileSync(configPath, 'utf8')) + if (process.cwd() === config.project) { + const platform = await import(pathToFileURL(join(config.pkg, 'skill/scripts/parent-platform.mjs')).href) + const { projectEndpoints } = await import(pathToFileURL(join(config.pkg, 'skill/scripts/parent-runtime.mjs')).href) + const log = (kind, detail) => appendFileSync(config.log_file, JSON.stringify({ at: new Date().toISOString(), kind, pid: process.pid, argv: process.argv, ...detail }) + '\n', { mode: 0o600 }) + const original = cp.spawn; let suspended = false + cp.spawn = function (...args) { + const [executable, argv, options] = args + const official = options?.cwd === config.project && Array.isArray(argv) && JSON.stringify(argv) === JSON.stringify(['app-server', '--listen', 'stdio://']) + let proof = null + if (official && !suspended && !existsSync(config.disarmed_file) && process.argv[1] === join(config.pkg, 'skill/scripts/parent-watch.mjs')) { + const spool = projectEndpoints(config.project).find(item => item.id === process.argv.at(-1)), saved = spool?.read(), watcher = platform.processIdentity(process.pid) + const probe = saved?.records.find(record => record.event.type === 'parent_probe' && record.state === 'sending' && sameIdentity(record.claim?.owner, watcher)) + if (saved?.harness === 'codex' && sameIdentity(saved.watcher, watcher) && platform.sameProcess(saved.caller.owner) && saved.state !== 'verified' && probe && realpathSync(executable) === realpathSync(saved.caller.owner.executable)) proof = { endpoint_id: spool.id, project: config.project, watcher: saved.watcher, caller: saved.caller, probe: probe, spool_before: saved, executable, spawn_argv: argv } + } + const child = Reflect.apply(original, this, args) + if (!official) return child + log('official_connection_spawn', { child_pid: child.pid, executable, spawn_argv: argv, owned_fault: Boolean(proof) }) + const write = child.stdin.write + child.stdin.write = function (...writeArgs) { log('rpc_write_raw', { child_pid: child.pid, raw: Buffer.isBuffer(writeArgs[0]) ? writeArgs[0].toString('utf8') : writeArgs[0] }); return Reflect.apply(write, this, writeArgs) } + child.stdout.on('data', chunk => log('rpc_read_raw', { child_pid: child.pid, raw: chunk.toString('utf8') })) + child.on('close', (code, signal) => log('official_connection_closed', { child_pid: child.pid, code, signal })) + if (proof) { + process.kill(child.pid, 'SIGSTOP'); suspended = true + const owner = platform.processIdentity(child.pid) + if (!owner || !platform.processDescendsFrom(owner, proof.watcher)) { process.kill(child.pid, 'SIGCONT'); fail('ACCEPTANCE_QUEUE_FAULT_CHILD_IDENTITY', '自己spawn childの開始identityが一致しません') } + Object.assign(proof, { owner, paused_at: new Date().toISOString() }) + writeFileSync(config.pause_file, JSON.stringify(proof, null, 2), { mode: 0o600 }); log('own_connection_sigstop', proof) + const started = Date.now() + setTimeout(() => { + const alive = platform.sameProcess(owner) + if (alive) { process.kill(owner.pid, 'SIGCONT'); log('own_connection_sigcont', { owner }) } + else log('own_connection_gone_before_resume', { owner }) + writeFileSync(config.terminal_file, JSON.stringify({ at: new Date().toISOString(), owner, elapsed_ms: Date.now() - started, still_same: alive }, null, 2), { mode: 0o600 }) + }, config.hold_ms).unref() + } + return child + } + syncBuiltinESMExports() + } +} diff --git a/experiments/parent-product-acceptance/scenarios-queue-fault.test.mjs b/experiments/parent-product-acceptance/scenarios-queue-fault.test.mjs new file mode 100644 index 00000000..f52a6118 --- /dev/null +++ b/experiments/parent-product-acceptance/scenarios-queue-fault.test.mjs @@ -0,0 +1,81 @@ +// RPC/期限observerのfocused。代替fixtureを実親の合格へ数えない。 +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync, renameSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { queueFaultRpcRows, queueProbeReceiverProof, queueProbeTimeoutProof, watchQueueProbeState, installQueueProbeFault, codexProbeJoinBoundary } from './scenarios-queue-fault.mjs' +import { codexFixtureEnvironmentArgs } from './scenarios-fixtures.mjs' + +const at = ms => new Date(ms).toISOString() +const pause = { endpoint_id: 'own', project: '/own project', caller: { conversation: 'CID' }, executable: '/codex', watcher: { pid: 20 }, owner: { pid: 21 }, paused_at: at(200), spool_before: { probe_deadline: 1000 } } +const connection = { kind: 'official_connection_spawn', at: at(100), pid: 10, child_pid: 11, executable: '/codex', spawn_argv: ['app-server', '--listen', 'stdio://'] } +const rpc = (kind, row, pid = 10, child_pid = 11) => ({ kind, pid, child_pid, at: at(150), raw: JSON.stringify(row) + '\n' }) +const owned = eventName => ({ eventName, command: '/node /pkg/hook codex', sourcePath: '/normal/hooks.json', handlerType: 'command', async: false, enabled: true, trustStatus: 'trusted' }) +const receiverEvents = () => [connection, ...[ + ['initialize', {}, {}], + ['thread/read', { threadId: 'CID', includeTurns: false }, { thread: { id: 'CID' } }], + ['thread/queue/list', { threadId: 'CID', limit: 1 }, { data: [] }], + ['hooks/list', { cwds: ['/own project'] }, { data: [{ hooks: [owned('postToolUse'), owned('stop')] }] }], +].flatMap(([method, params, result], i) => [rpc('rpc_write_raw', { id: i + 1, method, params }), rpc('rpc_read_raw', { id: i + 1, result })]), rpc('rpc_write_raw', { id: 1, method: 'initialize' }, 20, 21)] +const prove = events => queueProbeReceiverProof({ events, pause, expectedCommand: '/node /pkg/hook codex', expectedSource: '/normal/hooks.json' }) + +test('公式RPCは方向/接続ごとにchunkを連結し、未確定末尾は判定しない', () => { + const events = [rpc('rpc_read_raw', { id: 1, result: {} })] + const raw = events[0].raw + assert.deepEqual(queueFaultRpcRows([{ ...events[0], raw: raw.slice(0, 7) }, { ...events[0], raw: raw.slice(7) }])[0].row, { id: 1, result: {} }) + assert.equal(queueFaultRpcRows([{ ...events[0], raw: raw.slice(0, -1) }]).length, 0) + assert.throws(() => queueFaultRpcRows([{ ...events[0], raw: '非JSON\n' }]), { code: 'ACCEPTANCE_QUEUE_FAULT_RPC_CORRUPT' }) +}) + +test('receiver成功と初回queue受付前を照合し、別CID/不信頼/受付後を拒否する', () => { + assert.equal(prove(receiverEvents()).stopped_before_queue_add, true) + const other = receiverEvents(); other[3] = rpc('rpc_write_raw', { id: 2, method: 'thread/read', params: { threadId: 'foreign', includeTurns: false } }) + assert.throws(() => prove(other), { code: 'ACCEPTANCE_QUEUE_RECEIVER_CHECK_MISSING' }) + const untrusted = receiverEvents(); untrusted[8] = rpc('rpc_read_raw', { id: 4, result: { data: [{ hooks: [{ ...owned('postToolUse'), trustStatus: 'unknown' }, owned('stop')] }] } }) + assert.throws(() => prove(untrusted), { code: 'ACCEPTANCE_QUEUE_RECEIVER_CHECK_MISSING' }) + assert.throws(() => prove([...receiverEvents(), rpc('rpc_write_raw', { id: 2, method: 'thread/queue/add' }, 20, 21)]), { code: 'ACCEPTANCE_QUEUE_PROBE_FAULT_STAGE' }) +}) + +test('Codex MCP境界は同CID/turnの公式completed itemだけを読む', t => { + const dir = mkdtempSync(join(tmpdir(), 'peertable-probe-join-')); t.after(() => rmSync(dir, { recursive: true, force: true })) + const file = join(dir, 'rollout.jsonl'), query = { file, session: 'CID', project: '/own project', name: 'own', endpointId: 'endpoint' } + const rows = [{ type: 'session_meta', payload: { id: 'CID' } }, { timestamp: at(10), type: 'event_msg', payload: { type: 'item_completed', thread_id: 'CID', turn_id: 'turn', item: { type: 'McpToolCall', id: 'tool-use-id', server: 'peertable_parent', tool: 'parent_join', status: 'completed', arguments: { project: '/own project', name: 'own' }, result: { structuredContent: { schema: 'peertable.parent-join-result.v1', endpoint_id: 'endpoint', state: 'receiving' } } } } }] + const save = () => writeFileSync(file, rows.map(JSON.stringify).join('\n') + '\n') + save(); assert.equal(codexProbeJoinBoundary(query).native_tool_use_id, 'tool-use-id') + assert.equal(codexProbeJoinBoundary({ ...query, after: 1 }), null) + rows[1].payload.item.arguments.project = '/foreign'; save(); assert.equal(codexProbeJoinBoundary(query), null) + rows[1].payload.item.arguments.project = '/own project'; rows[0].payload.id = 'another CID'; save(); assert.equal(codexProbeJoinBoundary(query), null) + rows[0].payload.id = 'CID'; rows[1].payload.item.status = 'inProgress'; save(); assert.equal(codexProbeJoinBoundary(query), null) +}) + +test('期限failureは実spool/health両方を要求し、遅延や未指定誤差を成功にしない', () => { + const state = { endpoint_id: 'own', state: 'failed', runtime: 'failed', error_code: 'PARENT_PROBE_TIMEOUT' } + const observations = [{ at: at(1010), state }] + const health = { bridges: { parent_receiver: { endpoints: [{ endpoint_id: 'own', state: 'failed', beat_at: at(1005), detail: JSON.stringify(state) }] } } } + assert.equal(queueProbeTimeoutProof({ pause, observations, health, maxObservationLagMs: 15 }).failed_observation_lag_ms, 10) + assert.throws(() => queueProbeTimeoutProof({ pause, observations, health }), { code: 'ACCEPTANCE_QUEUE_DEADLINE_OBSERVATION_CONTRACT_MISSING' }) + assert.throws(() => queueProbeTimeoutProof({ pause, observations: [{ at: at(15478), state }], health, maxObservationLagMs: 15 }), { code: 'ACCEPTANCE_QUEUE_PROBE_TIMEOUT_LATE' }) + assert.throws(() => queueProbeTimeoutProof({ pause, observations, health: {}, maxObservationLagMs: 15 }), { code: 'ACCEPTANCE_QUEUE_PROBE_TIMEOUT_NOT_OBSERVED' }) +}) + +test('spool observerは実atomic renameを追い、本文と時刻を原保存する', async t => { + const dir = mkdtempSync(join(tmpdir(), 'peertable-probe-watch-')); t.after(() => rmSync(dir, { recursive: true, force: true })) + const file = join(dir, 'spool.json'), artifact = join(dir, 'observer.jsonl') + writeFileSync(file, JSON.stringify({ state: 'receiving', probe_deadline: Date.now() + 100 })) + const observer = watchQueueProbeState({ spool: { file }, artifact }); t.after(() => observer.close()) + const text = JSON.stringify({ state: 'failed', error_code: 'PARENT_PROBE_TIMEOUT', original: '原文<&\r\n' }) + writeFileSync(join(dir, 'tmp'), text); renameSync(join(dir, 'tmp'), file) + const end = Date.now() + 2000 + while (!observer.rows.some(row => row.state.state === 'failed')) { assert.ok(Date.now() < end); await new Promise(resolve => setTimeout(resolve, 10)) } + observer.close(); assert.equal(observer.error, null) + const saved = readFileSync(artifact, 'utf8').trim().split('\n').map(JSON.parse) + assert.equal(saved.find(row => row.state.state === 'failed').raw, text) +}) + +test('専用OS observerは起動前だけ、既存MCP環境参照を保持する', async () => { + const argv = codexFixtureEnvironmentArgs(['EXISTING', 'PEERTABLE_TOKEN_SOURCE_FILE'], { NODE_OPTIONS: '--import=own', PEERTABLE_ACCEPTANCE_QUEUE_PROBE: '/own/config' }) + assert.equal(argv[1], 'mcp_servers.peertable_parent.env_vars=["EXISTING","PEERTABLE_TOKEN_SOURCE_FILE","NODE_OPTIONS","PEERTABLE_ACCEPTANCE_QUEUE_PROBE"]') + assert.throws(() => codexFixtureEnvironmentArgs('不明形式'), { code: 'ACCEPTANCE_CODEX_ENV_VARS_SCHEMA' }) + await assert.rejects(installQueueProbeFault({ harness: 'codex', pty: 'already' }, {}), { code: 'ACCEPTANCE_QUEUE_FAULT_PREPARATION_ORDER' }) +}) From 64c1686ba307ec761f9976bf38814162b19b0fbf Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:20:35 +0900 Subject: [PATCH 60/72] =?UTF-8?q?probe=E6=9C=9F=E9=99=90=E3=81=AE=E5=85=B1?= =?UTF-8?q?=E9=80=9A=E8=A6=B3=E6=B8=AC=E4=B8=8A=E9=99=90=E3=82=92=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scenarios-native.mjs | 2 +- .../scenarios-queue-fault.mjs | 15 +++++++++------ .../scenarios-queue-fault.test.mjs | 14 +++++++++----- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/experiments/parent-product-acceptance/scenarios-native.mjs b/experiments/parent-product-acceptance/scenarios-native.mjs index bafeec11..ba1bcb33 100644 --- a/experiments/parent-product-acceptance/scenarios-native.mjs +++ b/experiments/parent-product-acceptance/scenarios-native.mjs @@ -674,7 +674,7 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { await s.until('35秒OS停止の自然終了/再開記録', () => own.fault.terminal(), 40000, 100) const evidence = { runner: own.runner, source: own.target.meta, related_session: own.target.meta.parent_session, pause: own.pause, receiver: own.receiver, join: own.join, state_observations: own.stateObserver.rows, health, terminal: own.fault.terminal(), rpc_os_events: own.fault.events(), transcript_raw: readFileSync(own.target.file, 'utf8'), watch_log: readFileSync(join(own.target.spool.root, 'watch.log'), 'utf8') } writeFileSync(own.evidenceFile, JSON.stringify(evidence, null, 2)) - // 正本に誤差契約が無い間は事実を保存してtyped未判定にする。独自秒数で期限合格を作らない。 + // 実測lagを保持し、共通controllerの観測上限だけを適用する。 const proof = queueProbeTimeoutProof({ pause: own.pause, observations: own.stateObserver.rows, health }) return s.result(s, 'probe_timeout_typed_failed', 'official_queue', { ...proof, related_session: own.target.meta.parent_session, turn_id: own.join.turn_id, original_artifact: own.evidenceFile, runner: own.runner }) } diff --git a/experiments/parent-product-acceptance/scenarios-queue-fault.mjs b/experiments/parent-product-acceptance/scenarios-queue-fault.mjs index 289a2e75..ba03a7d4 100644 --- a/experiments/parent-product-acceptance/scenarios-queue-fault.mjs +++ b/experiments/parent-product-acceptance/scenarios-queue-fault.mjs @@ -7,6 +7,8 @@ import cp from 'node:child_process' const fail = (code, message) => { throw Object.assign(new Error(message), { code }) } const sameIdentity = (a, b) => a && b && a.pid === b.pid && a.started === b.started +// 既存期限timerの実process focusedと同じ観測上限。製品の30秒期限を変更しない。 +export const QUEUE_PROBE_OBSERVATION_LIMIT_MS = 1500 // Codex公式itemのMCP結果を読む。execの自由文出力やモデルの報告を結果へ代用しない。 export function codexProbeJoinBoundary({ file, session, project, name, endpointId, after = -1 }) { @@ -75,8 +77,8 @@ export function watchQueueProbeState({ spool, artifact }) { return observer } -// 製品期限と観測誤差の契約を分ける。未提供の誤差値を推測して合格へ丸めない。 -export function queueProbeTimeoutProof({ pause, observations, health, maxObservationLagMs }) { +// 製品の実deadlineと、controllerがfailedを観測するまでの上限を別々に残す。 +export function queueProbeTimeoutProof({ pause, observations, health }) { const deadline = pause.spool_before.probe_deadline if (!Number.isFinite(deadline)) fail('ACCEPTANCE_QUEUE_PROBE_DEADLINE_MISSING', '実probe_deadlineがありません') const failed = observations.find(row => row.state.endpoint_id === pause.endpoint_id && row.state.state === 'failed' && row.state.runtime === 'failed' && row.state.error_code === 'PARENT_PROBE_TIMEOUT') @@ -85,10 +87,11 @@ export function queueProbeTimeoutProof({ pause, observations, health, maxObserva try { detail = typeof endpoint?.detail === 'string' ? JSON.parse(endpoint.detail) : endpoint?.detail } catch { fail('ACCEPTANCE_QUEUE_HEALTH_DETAIL_CORRUPT', '公式GETmembersのdetailがJSONではありません') } if (!failed || endpoint?.state !== 'failed' || detail?.endpoint_id !== pause.endpoint_id || detail.state !== 'failed' || detail.error_code !== 'PARENT_PROBE_TIMEOUT') fail('ACCEPTANCE_QUEUE_PROBE_TIMEOUT_NOT_OBSERVED', 'own endpointの実spoolとGETmembersに同じ期限failureがありません') const observationLag = Date.parse(failed.at) - deadline, healthLag = Date.parse(endpoint.beat_at) - deadline - const measurement = { probe_deadline: deadline, first_failed_at: failed.at, failed_observation_lag_ms: observationLag, failed_health_beat_at: endpoint.beat_at, failed_health_lag_ms: healthLag, failed_state: failed.state, health_endpoint: endpoint } - if (!Number.isFinite(maxObservationLagMs) || maxObservationLagMs < 0) throw Object.assign(new Error('30秒期限のOS/観測誤差契約が未指定です'), { code: 'ACCEPTANCE_QUEUE_DEADLINE_OBSERVATION_CONTRACT_MISSING', detail: measurement }) - if (!Number.isFinite(observationLag) || !Number.isFinite(healthLag) || observationLag < 0 || healthLag < 0 || observationLag > maxObservationLagMs || healthLag > maxObservationLagMs) throw Object.assign(new Error('実probe failureは期限と観測誤差契約の範囲を満たしません'), { code: 'ACCEPTANCE_QUEUE_PROBE_TIMEOUT_LATE', detail: measurement }) - return { ...measurement, max_observation_lag_ms: maxObservationLagMs } + const measurement = { probe_deadline: deadline, first_failed_at: failed.at, failed_observation_lag_ms: observationLag, failed_health_beat_at: endpoint.beat_at, failed_health_lag_ms: healthLag, failed_state: failed.state, health_endpoint: endpoint, max_observation_lag_ms: QUEUE_PROBE_OBSERVATION_LIMIT_MS } + if (!Number.isFinite(observationLag) || !Number.isFinite(healthLag)) throw Object.assign(new Error('実probe failureの時刻がありません'), { code: 'ACCEPTANCE_QUEUE_PROBE_TIMEOUT_TIME_MISSING', detail: measurement }) + if (observationLag < 0 || healthLag < 0) throw Object.assign(new Error('実probe deadlineより前にfailureを観測しました'), { code: 'ACCEPTANCE_QUEUE_PROBE_TIMEOUT_EARLY', detail: measurement }) + if (observationLag > QUEUE_PROBE_OBSERVATION_LIMIT_MS || healthLag > QUEUE_PROBE_OBSERVATION_LIMIT_MS) throw Object.assign(new Error('実probe failureはcontrollerの観測上限を超えました'), { code: 'ACCEPTANCE_QUEUE_PROBE_TIMEOUT_LATE', detail: measurement }) + return measurement } export async function installQueueProbeFault(fixture, { pkg, artifact, holdMs = 35000 }) { diff --git a/experiments/parent-product-acceptance/scenarios-queue-fault.test.mjs b/experiments/parent-product-acceptance/scenarios-queue-fault.test.mjs index f52a6118..e2bc9950 100644 --- a/experiments/parent-product-acceptance/scenarios-queue-fault.test.mjs +++ b/experiments/parent-product-acceptance/scenarios-queue-fault.test.mjs @@ -49,14 +49,18 @@ test('Codex MCP境界は同CID/turnの公式completed itemだけを読む', t => rows[0].payload.id = 'CID'; rows[1].payload.item.status = 'inProgress'; save(); assert.equal(codexProbeJoinBoundary(query), null) }) -test('期限failureは実spool/health両方を要求し、遅延や未指定誤差を成功にしない', () => { +test('期限failureは実spool/health両方と共通観測上限を要求する', () => { const state = { endpoint_id: 'own', state: 'failed', runtime: 'failed', error_code: 'PARENT_PROBE_TIMEOUT' } const observations = [{ at: at(1010), state }] const health = { bridges: { parent_receiver: { endpoints: [{ endpoint_id: 'own', state: 'failed', beat_at: at(1005), detail: JSON.stringify(state) }] } } } - assert.equal(queueProbeTimeoutProof({ pause, observations, health, maxObservationLagMs: 15 }).failed_observation_lag_ms, 10) - assert.throws(() => queueProbeTimeoutProof({ pause, observations, health }), { code: 'ACCEPTANCE_QUEUE_DEADLINE_OBSERVATION_CONTRACT_MISSING' }) - assert.throws(() => queueProbeTimeoutProof({ pause, observations: [{ at: at(15478), state }], health, maxObservationLagMs: 15 }), { code: 'ACCEPTANCE_QUEUE_PROBE_TIMEOUT_LATE' }) - assert.throws(() => queueProbeTimeoutProof({ pause, observations, health: {}, maxObservationLagMs: 15 }), { code: 'ACCEPTANCE_QUEUE_PROBE_TIMEOUT_NOT_OBSERVED' }) + assert.equal(queueProbeTimeoutProof({ pause, observations, health }).failed_observation_lag_ms, 10) + assert.equal(queueProbeTimeoutProof({ pause, observations, health }).max_observation_lag_ms, 1500) + assert.throws(() => queueProbeTimeoutProof({ pause, observations: [{ at: at(15478), state }], health }), { code: 'ACCEPTANCE_QUEUE_PROBE_TIMEOUT_LATE' }) + assert.throws(() => queueProbeTimeoutProof({ pause, observations: [{ at: at(2501), state }], health }), { code: 'ACCEPTANCE_QUEUE_PROBE_TIMEOUT_LATE' }) + assert.throws(() => queueProbeTimeoutProof({ pause, observations: [{ at: at(999), state }], health }), { code: 'ACCEPTANCE_QUEUE_PROBE_TIMEOUT_EARLY' }) + const earlyHealth = structuredClone(health); earlyHealth.bridges.parent_receiver.endpoints[0].beat_at = at(999) + assert.throws(() => queueProbeTimeoutProof({ pause, observations, health: earlyHealth }), { code: 'ACCEPTANCE_QUEUE_PROBE_TIMEOUT_EARLY' }) + assert.throws(() => queueProbeTimeoutProof({ pause, observations, health: {} }), { code: 'ACCEPTANCE_QUEUE_PROBE_TIMEOUT_NOT_OBSERVED' }) }) test('spool observerは実atomic renameを追い、本文と時刻を原保存する', async t => { From e8de1ec4d8e135597a1084466d70f05cbf7675e8 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:27:18 +0900 Subject: [PATCH 61/72] =?UTF-8?q?Codex=E3=81=AE=E7=9F=AD=E5=91=BDqueue?= =?UTF-8?q?=E6=8E=A5=E7=B6=9A=E3=82=92spawn=E3=81=8B=E3=82=89=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E8=A6=B3=E6=B8=AC=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scenarios-native.mjs | 39 +++++++++++++------ .../scenarios-native.test.mjs | 16 +++++++- .../scenarios-queue-fault.mjs | 36 ++++++++++++----- .../scenarios-queue-fault.test.mjs | 14 ++++++- 4 files changed, 81 insertions(+), 24 deletions(-) diff --git a/experiments/parent-product-acceptance/scenarios-native.mjs b/experiments/parent-product-acceptance/scenarios-native.mjs index ba1bcb33..5542dfdb 100644 --- a/experiments/parent-product-acceptance/scenarios-native.mjs +++ b/experiments/parent-product-acceptance/scenarios-native.mjs @@ -9,7 +9,7 @@ import { createScenarioContext, completedNativeInput, completedNativeInputProof import { createNativeFixtureFactory, waitOwnedFixtureExit } from './scenarios-fixtures.mjs' import { sha256 } from './evidence.mjs' import { createBackgroundSurfaceAdapters, nativeInvocation } from './scenarios-surfaces.mjs' -import { installQueueProbeFault, queueProbeReceiverProof, watchQueueProbeState, queueProbeTimeoutProof, codexProbeJoinBoundary } from './scenarios-queue-fault.mjs' +import { installQueueProbeFault, queueProbeReceiverProof, watchQueueProbeState, queueProbeTimeoutProof, codexProbeJoinBoundary, installQueueConnectionObserver, observedQueueConnections } from './scenarios-queue-fault.mjs' const fail = (code, message, detail) => { throw Object.assign(new Error(message), { code, detail }) } const rows = file => existsSync(file) ? readFileSync(file, 'utf8').split('\n').filter(Boolean).map(line => JSON.parse(line)) : [] @@ -65,6 +65,18 @@ export function queueConnectionProof(connections, checks) { return bound } +// 正式leaseは原queue/add応答と同じcloseを要求し、短命接続のsamplingへ戻らない。 +export function observedQueueConnectionProof(connections, checks) { + const bound = checks.map(check => { + const at = Date.parse(check.receipt.accepted_at) + const candidates = connections.filter(connection => connection.source === 'official_spawn_rpc_close' && connection.close_identity_alive === false && Date.parse(connection.first_seen_at) <= at && Date.parse(connection.closed_at) >= at && connection.delivery_id === check.delivery.delivery_id && connection.accepted.some(item => item.queued_submission_id === check.receipt.queued_submission_id)) + if (candidates.length !== 1) fail('ACCEPTANCE_QUEUE_CONNECTION_UNBOUND', '公式queue受付/配送ID/close/本人消失が同じ接続1個へ確定しません') + return { seq: check.original.seq, queued_submission_id: check.receipt.queued_submission_id, accepted_at: check.receipt.accepted_at, connection: candidates[0] } + }) + if (bound.length >= 2 && new Set(bound.map(item => `${item.connection.owner.pid}:${item.connection.owner.started}`)).size < 2) fail('ACCEPTANCE_QUEUE_CONNECTION_NOT_UPDATED', '複数配送で新しい公式接続を確認できません') + return bound +} + // 公式MCP包装のJSONだけを開き、本文の正規化や自由文からのerror推測をしない。 export function productToolError(value, expected) { if (typeof value === 'string') { try { return productToolError(JSON.parse(value), expected) } catch (error) { if (error instanceof SyntaxError) return null; throw error } } @@ -496,8 +508,9 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { const target = self(s), fixture = fixtureFor(target) let before = target.spool.read() if (s.harness === 'codex') { - const artifact = s.artifactFor(s, 'queue-connections'), monitor = monitorQueueConnections({ watcher: before.watcher, executable: before.caller.owner.executable, processIdentity: s.processIdentity, sameProcess: s.sameProcess, artifact }) - state(s).lease = { before, fixture, monitor, artifact, armed_at: new Date().toISOString() } + if (!fixture.queueObserver) fail('ACCEPTANCE_QUEUE_OBSERVER_NOT_PREPARED', '正式leaseの起動前spawn observerがありません') + const artifact = s.artifactFor(s, 'queue-connections') + state(s).lease = { before, fixture, observer: fixture.queueObserver, artifact, armed_at: new Date().toISOString() } return s.result(s, 'own_finite_lease', 'os_process', { mechanism: '公式queue接続更新', watcher: before.watcher, process_artifact: artifact, product_connection_source: join(s.pkg, 'skill/scripts/parent-receivers/codex.mjs') }) } const registered = await s.until('公式受信slotの実登録と存命owner', () => nativeLeaseRegistration(target, s), 120000, 100) @@ -512,9 +525,10 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { if (s.harness === 'codex') { const first = confirmOne(s) const closed = await s.until('queue受付後の公式接続終了', () => { - if (lease.monitor.error) throw lease.monitor.error - const at = Date.parse(first.receipt.accepted_at) - return lease.monitor.connections.find(connection => Date.parse(connection.first_seen_at) <= at && connection.closed_at && Date.parse(connection.closed_at) >= at) + const connections = observedQueueConnections({ events: lease.observer.events(), watcher: lease.before.watcher, endpointId: target.spool.id, session: target.meta.parent_session }) + writeFileSync(lease.artifact, JSON.stringify({ connections, rpc_os_events: lease.observer.events() }, null, 2)) + if (!connections.some(connection => connection.closed_at && connection.delivery_id === first.delivery.delivery_id)) return null + return observedQueueConnectionProof(connections, [first])[0].connection }, 30000, 100) if (target.spool.read().endpoint_id !== lease.before.endpoint_id || target.spool.read().cursor < lease.before.cursor) fail('ACCEPTANCE_QUEUE_LEASE_IDENTITY', '接続終了がendpoint/cursorを失いました') return s.result(s, 'finite_lease_control_not_body', 'official_queue', { mechanism: '公式queue接続更新', connection: closed, queued_submission_id: first.receipt.queued_submission_id, accepted_at: first.receipt.accepted_at, queue_snapshot: await lease.fixture.rpc('thread/queue/list', { threadId: target.meta.parent_session, limit: 100 }), process_artifact: lease.artifact, control_notification_required: false }) @@ -743,13 +757,13 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { for (const held of paused.values()) if (s.sameProcess(held.owner)) await pauseOwnedReceiver(held.owner, { target: held.target, fixture: held.fixture, sameProcess: s.sameProcess, processDescendsFrom: s.processDescendsFrom, paused, resume: true }) // queue撤去はこのfixtureが公式APIで作成したIDだけ。利用者/Aiterm実設定を削除しない。 for (const entry of current?.foreign?.queue ?? []) await current.foreign.fixture.rpc('thread/queue/delete', { threadId: self(s).meta.parent_session, queuedSubmissionId: entry.id }) - if (current?.lease?.monitor) { - const monitor = current.lease.monitor - for (const connection of monitor.connections) if (!connection.closed_at && !s.sameProcess(connection.owner)) connection.closed_at = new Date().toISOString() - await monitor.close() + if (current?.lease?.observer) { + const lease = current.lease, target = self(s) + await lease.observer.close() + const connections = observedQueueConnections({ events: lease.observer.events(), watcher: lease.before.watcher, endpointId: target.spool.id, session: target.meta.parent_session }) if (s.checks.length >= 2) { - const proof = queueConnectionProof(monitor.connections, s.checks) - writeFileSync(current.lease.artifact, JSON.stringify({ connections: monitor.connections, native_acceptance_proof: proof }, null, 2)) + const proof = observedQueueConnectionProof(connections, s.checks) + writeFileSync(lease.artifact, JSON.stringify({ connections, rpc_os_events: lease.observer.events(), native_acceptance_proof: proof }, null, 2)) } } } catch (error) { errors.push(error) } @@ -774,6 +788,7 @@ export async function createNativeScenarioContext(name, options) { let primary try { primary = await factory.open({ harness: options.sourceMeta.harness, name: `scenario-${name}-${randomUUID().slice(0, 8)}`, prepare: async fixture => { + if (name === 'lease' && fixture.harness === 'codex') fixture.queueObserver = await installQueueConnectionObserver(fixture, { pkg: options.pkg, artifact: label => join(fixture.directory, label) }) if (['final_race', 'session_change', 'foreign_ownership'].includes(name) && !(name === 'session_change' && ['cursor', 'grok'].includes(fixture.harness))) await fixture.addObserver({ events: fixture.harness === 'cursor' ? ['stop', 'postToolUse'] : name === 'session_change' ? ['SessionStart'] : ['Stop', 'PostToolUse'] }) if (fixture.harness === 'cursor' && ['idle', 'consecutive', 'no_external_tools'].includes(name)) await fixture.addObserver({ events: ['stop'] }) if (name === 'binding_deadline' && fixture.harness !== 'codex') await fixture.addObserver({ events: [fixture.harness === 'cursor' ? 'preToolUse' : 'PreToolUse'], appendToProductFile: fixture.harness === 'grok' }) diff --git a/experiments/parent-product-acceptance/scenarios-native.test.mjs b/experiments/parent-product-acceptance/scenarios-native.test.mjs index bb6ecf8a..02c14de7 100644 --- a/experiments/parent-product-acceptance/scenarios-native.test.mjs +++ b/experiments/parent-product-acceptance/scenarios-native.test.mjs @@ -6,7 +6,7 @@ import { createInterface } from 'node:readline' import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { competitorProof, queueConnectionProof, monitorQueueConnections, createNativeActions, productToolError, assertOwnedReceiver, assertOwnedResume, pauseOwnedReceiver, nativeLeaseRegistration, bindingContextProof, bindingToolError } from './scenarios-native.mjs' +import { competitorProof, queueConnectionProof, observedQueueConnectionProof, monitorQueueConnections, createNativeActions, productToolError, assertOwnedReceiver, assertOwnedResume, pauseOwnedReceiver, nativeLeaseRegistration, bindingContextProof, bindingToolError } from './scenarios-native.mjs' import { processIdentity, sameProcess, processDescendsFrom } from '../../skill/scripts/parent-platform.mjs' import { scenarioPlan } from './scenarios.mjs' import { digest } from '../../skill/scripts/parent-delivery.mjs' @@ -211,7 +211,7 @@ test('lease armのmodule path結合はendpoint再joinを呼ばず4harnessで文 const dir = mkdtempSync(join(tmpdir(), 'peertable-lease-path-')); t.after(() => rmSync(dir, { recursive: true, force: true })) const owner = processIdentity(process.pid) for (const harness of ['claude', 'codex', 'cursor', 'grok']) { - let rejoined = 0; const fixture = { harness, adapter: {}, join: async () => { rejoined++ } } + let rejoined = 0; const fixture = { harness, adapter: {}, queueObserver: { events: () => [], close: async () => {} }, join: async () => { rejoined++ } } const nativeName = harness === 'cursor' ? 'Shell' : 'run_terminal_command', input = { command: '専用登録入力' } const state = { state: 'verified', runtime: 'armed', endpoint_id: 'own', watcher: owner, caller: { owner }, waiter: { owner, native_task: { id: 'task', pid: owner.pid, process_identity: owner, input: { name: nativeName, input } } } } const target = { fixture, meta: { harness, parent_session: 'own' }, spool: { id: 'own', read: () => state }, observe: () => ({ toolUses: [{ name: nativeName, session: 'own', input, hook_input: harness === 'cursor' ? input : undefined, id: 'tool', turn_id: 'turn' }], tasks: [{ tool_use_id: 'tool', id: 'task', pid: owner.pid }] }) } @@ -223,3 +223,15 @@ test('lease armのmodule path結合はendpoint再joinを呼ばず4harnessで文 await native.finalize(scope) } }) + +// 原RPC/OS ledgerの照合単体。公式CLI配送とleaseの成立は実機で別に測る。 +test('lease接続証拠は公式queue応答と本人消失を要求し、sampling記録を合格へ使わない', () => { + const connection = (pid, delivery, queue, start, close) => ({ source: 'official_spawn_rpc_close', owner: { pid, started: String(pid) }, delivery_id: delivery, first_seen_at: new Date(start).toISOString(), closed_at: new Date(close).toISOString(), close_identity_alive: false, accepted: [{ queued_submission_id: queue }] }) + const check = (seq, time) => ({ original: { seq }, delivery: { delivery_id: 'delivery-' + seq }, receipt: { queued_submission_id: 'queue-' + seq, accepted_at: new Date(time).toISOString() } }) + const one = connection(1, 'delivery-1', 'queue-1', 100, 200), two = connection(2, 'delivery-2', 'queue-2', 250, 350) + assert.equal(observedQueueConnectionProof([one, two], [check(1, 150), check(2, 300)]).length, 2) + assert.throws(() => observedQueueConnectionProof([{ ...one, accepted: [] }], [check(1, 150)]), { code: 'ACCEPTANCE_QUEUE_CONNECTION_UNBOUND' }) + assert.throws(() => observedQueueConnectionProof([{ ...one, source: 'os_process' }], [check(1, 150)]), { code: 'ACCEPTANCE_QUEUE_CONNECTION_UNBOUND' }) + assert.throws(() => observedQueueConnectionProof([{ ...one, close_identity_alive: true }], [check(1, 150)]), { code: 'ACCEPTANCE_QUEUE_CONNECTION_UNBOUND' }) + assert.throws(() => observedQueueConnectionProof([one, { ...two, owner: one.owner }], [check(1, 150), check(2, 300)]), { code: 'ACCEPTANCE_QUEUE_CONNECTION_NOT_UPDATED' }) +}) diff --git a/experiments/parent-product-acceptance/scenarios-queue-fault.mjs b/experiments/parent-product-acceptance/scenarios-queue-fault.mjs index ba03a7d4..20074cc1 100644 --- a/experiments/parent-product-acceptance/scenarios-queue-fault.mjs +++ b/experiments/parent-product-acceptance/scenarios-queue-fault.mjs @@ -94,11 +94,14 @@ export function queueProbeTimeoutProof({ pause, observations, health }) { return measurement } -export async function installQueueProbeFault(fixture, { pkg, artifact, holdMs = 35000 }) { +export async function installQueueProbeFault(fixture, options) { return installQueueObserver(fixture, { ...options, pauseProbe: true }) } +export async function installQueueConnectionObserver(fixture, options) { return installQueueObserver(fixture, { ...options, pauseProbe: false }) } + +async function installQueueObserver(fixture, { pkg, artifact, holdMs = 35000, pauseProbe }) { if (fixture.harness !== 'codex' || fixture.pty) fail('ACCEPTANCE_QUEUE_FAULT_PREPARATION_ORDER', '専用Codex起動前だけにOS故障observerを準備します') - if (process.platform === 'win32') fail('ACCEPTANCE_QUEUE_FAULT_OS_API_UNCONFIRMED', 'Windowsの所有childを停止/再開する公式OS APIはまだ実測していません') + if (pauseProbe && process.platform === 'win32') fail('ACCEPTANCE_QUEUE_FAULT_OS_API_UNCONFIRMED', 'Windowsの所有childを停止/再開する公式OS APIはまだ実測していません') const configFile = artifact('probe-fault-config'), logFile = artifact('probe-rpc-os.jsonl'), pauseFile = artifact('probe-suspended'), terminalFile = artifact('probe-terminal'), disarmedFile = artifact('probe-fault-disarmed') - const config = { pkg, project: fixture.project, hold_ms: holdMs, log_file: logFile, pause_file: pauseFile, terminal_file: terminalFile, disarmed_file: disarmedFile } + const config = { pkg, project: fixture.project, hold_ms: holdMs, pause_probe: pauseProbe, log_file: logFile, pause_file: pauseFile, terminal_file: terminalFile, disarmed_file: disarmedFile } writeFileSync(configFile, JSON.stringify(config, null, 2), { mode: 0o600 }) fixture.processEnvironment = { NODE_OPTIONS: [process.env.NODE_OPTIONS, `--import=${pathToFileURL(fileURLToPath(import.meta.url)).href}`].filter(Boolean).join(' '), PEERTABLE_ACCEPTANCE_QUEUE_PROBE: configFile } const platform = await import(pathToFileURL(join(pkg, 'skill/scripts/parent-platform.mjs')).href) @@ -120,6 +123,17 @@ export async function installQueueProbeFault(fixture, { pkg, artifact, holdMs = } } +// samplingを使わず、実spawn/queue応答/closeを同じ接続とspool claimへ結ぶ。 +export function observedQueueConnections({ events, watcher, endpointId, session }) { + const rpc = queueFaultRpcRows(events) + return events.filter(event => event.kind === 'official_connection_spawn' && event.owned_queue && sameIdentity(event.queue_owner, watcher) && event.endpoint_id === endpointId && event.session === session && event.child_owner?.started).map(event => { + const closed = events.find(row => row.kind === 'official_connection_closed' && row.pid === event.pid && sameIdentity(row.child_owner, event.child_owner) && row.at >= event.at) + const requests = rpc.filter(row => row.pid === event.pid && row.child_pid === event.child_pid && row.kind === 'rpc_write_raw' && row.row.method === 'thread/queue/add' && row.row.params?.threadId === session && row.row.params.clientUserMessageId === event.queue_record.delivery_id) + const accepted = requests.flatMap(request => rpc.filter(row => row.pid === event.pid && row.child_pid === event.child_pid && row.kind === 'rpc_read_raw' && row.row.id === request.row.id && row.row.result?.queuedSubmission?.id).map(response => ({ request, response, queued_submission_id: response.row.result.queuedSubmission.id }))) + return { owner: event.child_owner, watcher: event.queue_owner, endpoint_id: endpointId, session, delivery_id: event.queue_record.delivery_id, first_seen_at: event.at, closed_at: closed?.at ?? null, close_identity_alive: closed?.child_identity_alive ?? null, accepted, source: 'official_spawn_rpc_close', raw_spawn: event, raw_close: closed ?? null } + }) +} + // --importは自己projectの子だけに継承する。spawn/RPCの引数・戻り値・callbackは一切変更しない。 const configPath = process.env.PEERTABLE_ACCEPTANCE_QUEUE_PROBE if (configPath && existsSync(configPath)) { @@ -132,19 +146,23 @@ if (configPath && existsSync(configPath)) { cp.spawn = function (...args) { const [executable, argv, options] = args const official = options?.cwd === config.project && Array.isArray(argv) && JSON.stringify(argv) === JSON.stringify(['app-server', '--listen', 'stdio://']) - let proof = null - if (official && !suspended && !existsSync(config.disarmed_file) && process.argv[1] === join(config.pkg, 'skill/scripts/parent-watch.mjs')) { + let proof = null, queueProof = null + if (official && process.argv[1] === join(config.pkg, 'skill/scripts/parent-watch.mjs')) { const spool = projectEndpoints(config.project).find(item => item.id === process.argv.at(-1)), saved = spool?.read(), watcher = platform.processIdentity(process.pid) - const probe = saved?.records.find(record => record.event.type === 'parent_probe' && record.state === 'sending' && sameIdentity(record.claim?.owner, watcher)) - if (saved?.harness === 'codex' && sameIdentity(saved.watcher, watcher) && platform.sameProcess(saved.caller.owner) && saved.state !== 'verified' && probe && realpathSync(executable) === realpathSync(saved.caller.owner.executable)) proof = { endpoint_id: spool.id, project: config.project, watcher: saved.watcher, caller: saved.caller, probe: probe, spool_before: saved, executable, spawn_argv: argv } + const record = saved?.records.find(record => record.state === 'sending' && record.claim?.channel === 'codex_queue' && sameIdentity(record.claim.owner, watcher)) + if (saved?.harness === 'codex' && sameIdentity(saved.watcher, watcher) && platform.sameProcess(saved.caller.owner) && record && realpathSync(executable) === realpathSync(saved.caller.owner.executable)) { + queueProof = { endpoint_id: spool.id, session: saved.caller.conversation, queue_owner: watcher, queue_record: record } + if (config.pause_probe && !suspended && !existsSync(config.disarmed_file) && saved.state !== 'verified' && record.event.type === 'parent_probe') proof = { endpoint_id: spool.id, project: config.project, watcher: saved.watcher, caller: saved.caller, probe: record, spool_before: saved, executable, spawn_argv: argv } + } } const child = Reflect.apply(original, this, args) if (!official) return child - log('official_connection_spawn', { child_pid: child.pid, executable, spawn_argv: argv, owned_fault: Boolean(proof) }) + const childOwner = config.pause_probe ? null : platform.processIdentity(child.pid) + log('official_connection_spawn', { child_pid: child.pid, child_owner: childOwner, executable, spawn_argv: argv, owned_fault: Boolean(proof), owned_queue: Boolean(queueProof), ...queueProof }) const write = child.stdin.write child.stdin.write = function (...writeArgs) { log('rpc_write_raw', { child_pid: child.pid, raw: Buffer.isBuffer(writeArgs[0]) ? writeArgs[0].toString('utf8') : writeArgs[0] }); return Reflect.apply(write, this, writeArgs) } child.stdout.on('data', chunk => log('rpc_read_raw', { child_pid: child.pid, raw: chunk.toString('utf8') })) - child.on('close', (code, signal) => log('official_connection_closed', { child_pid: child.pid, code, signal })) + child.on('close', (code, signal) => log('official_connection_closed', { child_pid: child.pid, child_owner: childOwner, code, signal, child_identity_alive: childOwner ? Boolean(platform.sameProcess(childOwner)) : null })) if (proof) { process.kill(child.pid, 'SIGSTOP'); suspended = true const owner = platform.processIdentity(child.pid) diff --git a/experiments/parent-product-acceptance/scenarios-queue-fault.test.mjs b/experiments/parent-product-acceptance/scenarios-queue-fault.test.mjs index e2bc9950..edc765fc 100644 --- a/experiments/parent-product-acceptance/scenarios-queue-fault.test.mjs +++ b/experiments/parent-product-acceptance/scenarios-queue-fault.test.mjs @@ -4,7 +4,7 @@ import assert from 'node:assert/strict' import { mkdtempSync, rmSync, writeFileSync, renameSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { queueFaultRpcRows, queueProbeReceiverProof, queueProbeTimeoutProof, watchQueueProbeState, installQueueProbeFault, codexProbeJoinBoundary } from './scenarios-queue-fault.mjs' +import { queueFaultRpcRows, queueProbeReceiverProof, queueProbeTimeoutProof, watchQueueProbeState, installQueueProbeFault, codexProbeJoinBoundary, observedQueueConnections } from './scenarios-queue-fault.mjs' import { codexFixtureEnvironmentArgs } from './scenarios-fixtures.mjs' const at = ms => new Date(ms).toISOString() @@ -83,3 +83,15 @@ test('専用OS observerは起動前だけ、既存MCP環境参照を保持する assert.throws(() => codexFixtureEnvironmentArgs('不明形式'), { code: 'ACCEPTANCE_CODEX_ENV_VARS_SCHEMA' }) await assert.rejects(installQueueProbeFault({ harness: 'codex', pty: 'already' }, {}), { code: 'ACCEPTANCE_QUEUE_FAULT_PREPARATION_ORDER' }) }) + +test('短命queue接続はspawn時本人/claim/RPC応答/closeを直接相関する', () => { + const owner = { pid: 21, started: 'child-start' }, watcher = { pid: 20, started: 'watcher-start' } + const start = { ...connection, pid: watcher.pid, child_pid: owner.pid, child_owner: owner, queue_owner: watcher, owned_queue: true, endpoint_id: 'own', session: 'CID', queue_record: { delivery_id: 'delivery' } } + const events = [start, rpc('rpc_write_raw', { id: 2, method: 'thread/queue/add', params: { threadId: 'CID', clientUserMessageId: 'delivery' } }, watcher.pid, owner.pid), rpc('rpc_read_raw', { id: 2, result: { queuedSubmission: { id: 'queue-accepted' } } }, watcher.pid, owner.pid), { kind: 'official_connection_closed', at: at(151), pid: watcher.pid, child_pid: owner.pid, child_owner: owner, child_identity_alive: false }] + const query = { events, watcher, endpointId: 'own', session: 'CID' } + const found = observedQueueConnections(query) + assert.equal(found.length, 1); assert.equal(found[0].closed_at, at(151)); assert.equal(found[0].accepted[0].queued_submission_id, 'queue-accepted') + assert.equal(observedQueueConnections({ ...query, session: 'foreign' }).length, 0) + assert.equal(observedQueueConnections({ ...query, watcher: { ...watcher, started: 'reused' } }).length, 0) + events[3].child_owner = { ...owner, started: 'reused' }; assert.equal(observedQueueConnections(query)[0].closed_at, null) +}) From c3a1874c91066f3b6bb0de0e87b7589e3f126303 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:31:29 +0900 Subject: [PATCH 62/72] =?UTF-8?q?=E6=9C=AA=E8=A7=A3=E6=98=8E=E3=81=AEOS?= =?UTF-8?q?=E5=A2=83=E7=95=8C=E3=81=A8=E5=AE=9F=E6=A9=9F=E8=A9=A6=E9=A8=93?= =?UTF-8?q?=E3=81=AE=E7=8F=BE=E5=9C=A8=E5=9C=B0=E3=82=92=E6=AD=A3=E3=81=97?= =?UTF-8?q?=E3=81=8F=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plan_parent-native-delivery.md | 4 + .../parent-product-acceptance/README.md | 4 + rag/INDEX.md | 3 + .../claude-own-session-hook-diagnosis.json | 127 + rag/parent-delivery/cli-lifecycle-repairs.md | 4 + .../release-candidate-checkpoint.json | 58 +- .../sources/node-v24.20.0-libuv-win-fs.c.txt | 3811 +++++++++++++++++ .../node-v24.20.0-libuv-win-fs.metadata.json | 17 + .../windows-rename-ci-diagnosis.json | 21 + 9 files changed, 4029 insertions(+), 20 deletions(-) create mode 100644 rag/parent-delivery/claude-own-session-hook-diagnosis.json create mode 100644 rag/parent-delivery/sources/node-v24.20.0-libuv-win-fs.c.txt create mode 100644 rag/parent-delivery/sources/node-v24.20.0-libuv-win-fs.metadata.json create mode 100644 rag/parent-delivery/windows-rename-ci-diagnosis.json diff --git a/docs/plan_parent-native-delivery.md b/docs/plan_parent-native-delivery.md index 671ae691..0a676a9b 100644 --- a/docs/plan_parent-native-delivery.md +++ b/docs/plan_parent-native-delivery.md @@ -395,3 +395,7 @@ macOSの短命process終了時の成功・空出力の誤分類と、Linux Grok controllerはCursorの公式postToolUseが確定した入力全体を照合する。Windows Grokの公式罫線内の空promptは原画面fixtureで判定する。Claudeのhook無効化は同じ実会話を公式CLIの自己run設定でresumeし、共有設定を保持して無効化・復元の実因果を測定する。controllerのfocused合格だけで正式受入を埋めない。 現行manifestの正式受入は再測定中で、長時間leaseの合格もまだ無い。Linux Cursorは本人ログイン待ち、Desktop/IDEはJevの画面取得の解決または操作ツール変更の回答待ちである。新候補の3 OS製品CI、全必須面の実機受入、main着地、npm公開・registry導入・本番反映を順に完了する。 + +正式probe期限試験のcontrollerは、製品の実30秒期限と失敗の観測時刻を分ける。全OS・全harnessに共通の観測上限は既存focused timer試験と同じ1500msとし、失敗の実lagを原値で保存する。期限前、時刻欠落、health不一致、上限超過をtyped failureにし、製品deadlineを延ばして合格へ丸めない。Windowsの所有child停止APIと各harnessの故障発火が実測されていない行は、引き続き未合格とする。 + +再登録修理後のWindows CIでは、Bakery ticketのatomic置換で実`EPERM`が発生した。[原記録](../rag/parent-delivery/windows-rename-ci-diagnosis.json)を保持し、同runtimeの先行CI成功を今回の失敗解決へ代用しない。Linux Grokの初回probeとWindows Cursorのlock timeoutも撤回候補のrawを保ち、原因を照合する。現行候補の正式配送試験と公開は、その境界の再現調査中である。 diff --git a/experiments/parent-product-acceptance/README.md b/experiments/parent-product-acceptance/README.md index ae6af041..e197c720 100644 --- a/experiments/parent-product-acceptance/README.md +++ b/experiments/parent-product-acceptance/README.md @@ -57,3 +57,7 @@ Codexは`Stop`/`PostToolUse` hookの出力を``要素へ入れ、 focused test: `node --test experiments/parent-product-acceptance/evidence.test.mjs experiments/parent-product-acceptance/scenarios.test.mjs` lease単独runは`scenarios-lease-run.mjs`へ導入物・tarball SHA・製品source/digest/version・`--runner-commit`を渡す。短時間runとroom/processを分離し、実際の製品期限まで待つ。通常HOMEと公式認証を維持し、global設定や認証を長時間fixtureへ複製しない。実行moduleに未commit差分があればprovenance照合で止まる。 + +Codexのprobe期限試験は`scenarios-queue-fault.mjs`で、正規MCPのreceiver検証後に自己watcherが生成した初回app-serverだけをOS停止する。製品のspool・時計・RPC応答は変更しない。原RPC、同CIDのMCP completed item、PID/start、実deadlineとspool/room healthを保存する。期限failureの観測上限は全OS共通1500msで、実lagを保存し、期限前・時刻欠落・health不一致・上限超過は失敗にする。Windowsの実OS停止APIは未確認であり、試験手順の実装を実機合格と扱わない。 + +Codexの通常lease試験は起動前に自己processへ読取りobserverを設定し、短命app-serverの実spawnから公式queue/add request/responseとcloseまでを同じPID/startで記録する。OS停止は行わない。20msのprocess samplingで見逃した接続を成功に数えず、配送ID・受付ID・実接続終了・同identity消失が一致する原証拠を要求する。複数配送は別の実接続へ照合する。 diff --git a/rag/INDEX.md b/rag/INDEX.md index c36c8740..c1d51398 100644 --- a/rag/INDEX.md +++ b/rag/INDEX.md @@ -12,3 +12,6 @@ - [Codex初回probeの実期限超過](parent-delivery/codex-probe-deadline-diagnosis.json) — 実app-serverの停止・自然timeout・room healthを照合。30秒期限への14.478秒の遅延を保持する修理診断。 - [Codex probe timer修理後の実診断](parent-delivery/codex-probe-timer-repair-diagnosis.json) — 同じOS障害で実期限10ms後のroom failedを観測し、生timeline・PID・Git blobを独立照合。正式受入への算入は0件。 - [Codex同会話での再登録失敗](parent-delivery/codex-probe-rejoin-diagnosis.json) — 自然timeout後の正規再登録でもfailed probeが不変となる実再現。開始時controller bytesと製品Git blobを照合し、正式合格へ算入しない。 + +- [Windows競合CIのrename失敗](parent-delivery/windows-rename-ci-diagnosis.json) — transaction ticketの実EPERMを保存し、原因をfocused再現で調査中。[実Node版のlibuv一次原文](parent-delivery/sources/node-v24.20.0-libuv-win-fs.metadata.json)は原因を断定する証拠と分ける。 +- [Claude同会話のhook無効化・復帰](parent-delivery/claude-own-session-hook-diagnosis.json) — 公式session settingsと同CIDの3 MCP結果を原transcriptへ独立照合。復帰後の本文配送はこの準備診断では検証していない。 diff --git a/rag/parent-delivery/claude-own-session-hook-diagnosis.json b/rag/parent-delivery/claude-own-session-hook-diagnosis.json new file mode 100644 index 00000000..b801bc82 --- /dev/null +++ b/rag/parent-delivery/claude-own-session-hook-diagnosis.json @@ -0,0 +1,127 @@ +{ + "schema": "peertable.parent-claude-hook-diagnosis.v1", + "source_commit": "84de74a097469ef4abd43698635805696b1302ff", + "runtime_digest": "778b09708b12cdd808a1d7d8451aad5b34131ec9b5207f7bb50e4313f525ee7f", + "harness_version": "2.1.284 (Claude Code)", + "parent_session": "f012f4ff-853b-4da3-bb30-a526726c52b0", + "endpoint_id": "4376e283-52e1-4b3f-9072-da1e0896e17c", + "product_live": false, + "formal_acceptance_credit": 0, + "independent_native_transcript_review": true, + "started_at": "2026-09-28T20:57:33.537Z", + "finished_at": "2026-09-28T20:58:12.230Z", + "normal_probe_literal": "fffc1fa8-7397-469b-9148-72f5e7fccedf", + "normal_probe_assistant_entry": "a178ecfe-d369-4c20-aa1b-4a43e8528313", + "official_mcp_tool_results": [ + { + "tool_use_id": "toolu_01Gc36oaSyu1NCLYLguYCeg2", + "use_order": 43, + "result_order": 45, + "value": { + "schema": "peertable.parent-join-result.v1", + "endpoint_id": "4376e283-52e1-4b3f-9072-da1e0896e17c", + "state": "receiving", + "harness": "claude", + "wait_process": null, + "actor_environment": null, + "error_code": null + } + }, + { + "tool_use_id": "toolu_01SfDCgQGVuk1tBb4K8ANZGc", + "use_order": 71, + "result_order": 72, + "value": { + "schema": "peertable.parent-error.v1", + "state": "failed", + "error_code": "PARENT_CALLER_UNBOUND", + "detail": "PARENT_CALLER_UNBOUND" + } + }, + { + "tool_use_id": "toolu_016sscg7nV6doWGz1ZSzJvsd", + "use_order": 87, + "result_order": 89, + "value": { + "schema": "peertable.parent-join-result.v1", + "endpoint_id": "4376e283-52e1-4b3f-9072-da1e0896e17c", + "state": "verified", + "harness": "claude", + "wait_process": null, + "actor_environment": null, + "error_code": null + } + } + ], + "fixture_native_owners": [ + { + "pid": 21381, + "parent": 21365, + "started": "Tue Sep 29 05:57:34 2026", + "executable": "/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe", + "command": "/opt/homebrew/bin/claude --settings /Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/.lease-execution-017-b639/diagnostics/claude-hook-complete/native-fixtures/claude-a1684e3b-e0eb-4568-b9f4-e8c0255d2a6a/project/session-settings.json" + }, + { + "pid": 22928, + "parent": 22913, + "started": "Tue Sep 29 05:57:52 2026", + "executable": "/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe", + "command": "/opt/homebrew/bin/claude --resume f012f4ff-853b-4da3-bb30-a526726c52b0 --settings /Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/.lease-execution-017-b639/diagnostics/claude-hook-complete/native-fixtures/claude-a1684e3b-e0eb-4568-b9f4-e8c0255d2a6a/project/session-settings.json" + }, + { + "pid": 23488, + "parent": 23469, + "started": "Tue Sep 29 05:58:01 2026", + "executable": "/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe", + "command": "/opt/homebrew/bin/claude --resume f012f4ff-853b-4da3-bb30-a526726c52b0 --settings /Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/.lease-execution-017-b639/diagnostics/claude-hook-complete/native-fixtures/claude-a1684e3b-e0eb-4568-b9f4-e8c0255d2a6a/project/session-settings.json" + } + ], + "hook_fault": { + "official_option": "--settings", + "disabled": { + "permissions": { + "allow": [ + "mcp__peertable_parent__parent_join", + "mcp__peertable_parent__parent_read", + "mcp__peertable_parent__parent_leave" + ] + }, + "disableAllHooks": true + }, + "restored": { + "permissions": { + "allow": [ + "mcp__peertable_parent__parent_join", + "mcp__peertable_parent__parent_read", + "mcp__peertable_parent__parent_leave" + ] + } + }, + "global_changed_reported": false, + "global_bytes_equality": "専用controllerの比較結果。初期global原bytesをrootが独立再照合したとは主張しない。" + }, + "restored_body_delivery_verified": false, + "fixture_closed": true, + "server_gone": true, + "errors": [], + "private_raw": "/Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/.lease-execution-017-b639/diagnostics/claude-hook-complete", + "files": [ + { + "file": "/Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/.lease-execution-017-b639/diagnostics/claude-hook-complete/result.json", + "sha256": "9cf4d2670a673cb44d7eaeaa5479fa0ea8f725ba48647d201d0a1f5461d87f55" + }, + { + "file": "/Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/.lease-execution-017-b639/diagnostics/claude-hook-complete/reproduce.mjs", + "sha256": "bb98ca2e7199bcb58fadc67d47f5e11e425ea07b7c39c51b6eb065adc2583d77" + }, + { + "file": "/Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/.lease-execution-017-b639/diagnostics/claude-hook-complete/startup-84.mjs", + "sha256": "03c251cf85d703ed3d96b2c3056342879d5e668841aaca83ea76eef4a7496164" + }, + { + "file": "/Users/kite/.claude/projects/-Users-kite--codex-worktrees-parent-cli-scenarios-peertable--lease-execution-017-b639-diagnostics-claude-hook-complete-native-fixtures-claude-a1684e3b-e0eb-4568-b9f4-e8c0255d2a6a-project/f012f4ff-853b-4da3-bb30-a526726c52b0.jsonl", + "sha256": "94ff7ecd909b486db7576076d51aeed3c4c8fa6c6abe2df1ce494ccd8acaf8dc" + } + ], + "startup_source_review": "84deのharness.mjsから切り出したstartup関数本文が一致。captureには末尾改行が1byte多く、全moduleのbyte一致とは扱わない。" +} diff --git a/rag/parent-delivery/cli-lifecycle-repairs.md b/rag/parent-delivery/cli-lifecycle-repairs.md index a3ce169c..e3578dc3 100644 --- a/rag/parent-delivery/cli-lifecycle-repairs.md +++ b/rag/parent-delivery/cli-lifecycle-repairs.md @@ -61,3 +61,7 @@ Cursorのidle確認には[公式stop仕様](https://cursor.com/docs/hooks#stop) [実診断](codex-probe-rejoin-diagnosis.json)は初回app-serverを1回だけOS停止し、製品の自然timeoutと期限失敗の後に、同じ会話から正規`parent_join`を1回呼んだ。watcher本人とendpointは維持されたが、失敗probeのIDと記録が不変で、更新した30秒の期限でも再度failedになった。製品state・時計・RPC応答を変更していない。rootは[開始時bytesと製品Git blob](codex-probe-rejoin-fingerprints.json)を照合した。実装継続で変更されたcontrollerについては、診断開始時に保存したbytesを照合対象にした。 `startEndpoint`の生存watcherによる早期returnより前にprobeを保存し、明示joinでは現在の既知failed probeだけを新しい符号へ更新する。新probeの実受信完了後に旧failed synthetic probeへ`resolved_by`を追加する。原文・失敗状態・cursorを保持し、結果不明のprobeやDMの失敗を解決しない。配送29件、実join内部契約1件、watcher継続3件の関連focused試験が合格した。修正版の正規MCPによる再登録・新probe受信・後続DM・healthは再測定待ちであり、正式合格を増やしていない。 + +## Claudeの自己会話でのhook故障 + +[専用診断](claude-own-session-hook-diagnosis.json)は公式`--settings`の`disableAllHooks`だけで、同じ会話の自己CLIを停止・resumeした。正常joinと確認符号のモデル返答、無効化中の`PARENT_CALLER_UNBOUND`、復帰後の同endpoint・`verified`を、同CIDの公式MCP tool IDと対応するuser tool_resultへrootが独立照合した。専用controllerはglobalのbytes不変更を報告しているが、rootの独立監査は保存済み原transcriptと開始関数へ限定する。復帰後の新本文配送はこの診断では試していない。撤回候補の準備診断であり、正式受入へ算入しない。 diff --git a/rag/parent-delivery/release-candidate-checkpoint.json b/rag/parent-delivery/release-candidate-checkpoint.json index e3c59bca..671484e8 100644 --- a/rag/parent-delivery/release-candidate-checkpoint.json +++ b/rag/parent-delivery/release-candidate-checkpoint.json @@ -1,21 +1,22 @@ { "schema": "peertable.parent-release-checkpoint.v1", - "source_commit": "84de74a097469ef4abd43698635805696b1302ff", - "runtime_digest": "778b09708b12cdd808a1d7d8451aad5b34131ec9b5207f7bb50e4313f525ee7f", + "source_commit": "c97b431cbcb75e07a45cc17de6d688442b736af3", + "runtime_digest": "ddaadee7f33139c2a7ea8d08b36d87446b3221b85c817b9a3e1afff34193186f", "package_version": "0.8.63", - "candidate_state": "withdrawn_rejoin_defect", + "candidate_state": "held_for_windows_persistence_diagnosis", "source_frozen": false, - "previous_candidate_checkpoint": "rag/parent-delivery/snapshots/017bf2c/release-candidate-checkpoint.json", + "previous_candidate_checkpoint": "rag/parent-delivery/snapshots/84de74a/release-candidate-checkpoint.json", "product_ci": { - "status": "success", - "source_commit": "84de74a097469ef4abd43698635805696b1302ff", - "head_commit": "d9cb8d59f63d667763b7d195c1b61e9191041e25", - "url": "https://github.com/kitepon/peertable/actions/runs/36482248916", + "status": "failure", + "source_commit": "c97b431cbcb75e07a45cc17de6d688442b736af3", + "head_commit": "c97b431cbcb75e07a45cc17de6d688442b736af3", + "url": "https://github.com/kitepon/peertable/actions/runs/36485234948", "native_os": { "darwin": "passed", "linux": "passed", - "win32": "passed" - } + "win32": "failed" + }, + "failure_evidence": "rag/parent-delivery/windows-rename-ci-diagnosis.json" }, "focused_verification": { "darwin_process_exit": { @@ -51,6 +52,20 @@ "health_delay_ms": 10, "formal_acceptance_credit": 0, "evidence": "rag/parent-delivery/codex-probe-timer-repair-diagnosis.json" + }, + "rejoin_probe_record_preservation": { + "passed": 1, + "failed": 0 + }, + "native_probe_fault_controller": { + "passed": 32, + "failed": 0, + "live_acceptance_credit": 0 + }, + "codex_spawn_lease_controller": { + "passed": 23, + "failed": 0, + "live_acceptance_credit": 0 } }, "documentation_tests": { @@ -59,21 +74,21 @@ "skipped": 0 }, "package": { - "status": "fixture_installed", - "sha256": "9f01372357e2b302f5fb241ab3e79fd78d7424ad13e047c4c214c5831eb198c8", + "status": "fixture_installed_acceptance_held", + "sha256": "823336e4397dabc77001d03dc57089822e1b3e48fbd2b4248623323007796329", "files": 133, "dry_run_files": 133, - "installed_runtime_digest": "778b09708b12cdd808a1d7d8451aad5b34131ec9b5207f7bb50e4313f525ee7f", + "installed_runtime_digest": "ddaadee7f33139c2a7ea8d08b36d87446b3221b85c817b9a3e1afff34193186f", "registry_install": false, "skill_placement": { "status": "required", "reason": "専用prefixのfixture導入はignore-scriptsを使い、既存のglobalスキル配置を変更していない。registryのglobal installは公開後に行う。" }, "parent_connections": { - "codex": "registered", - "claude": "registered", - "cursor": "registered", - "grok": "registered" + "codex": "registered_fixed_candidate_for_diagnostic", + "claude": "previous_candidate", + "cursor": "previous_candidate", + "grok": "previous_candidate" } }, "product_acceptance": { @@ -87,11 +102,13 @@ "checkpoint_counts_as_live_acceptance": false }, "lease": { - "status": "withdrawal_requested", + "status": "withdrawn_monitors_finished_cleanup_review_pending", "reported_live_before_withdrawal": 2, "passed": 0, "source_commit": "84de74a097469ef4abd43698635805696b1302ff", - "reason": "製品runtimeの再登録欠陥で候補撤回。原記録を保持し、所有試験だけを停止する。" + "reason": "製品runtimeの再登録欠陥で候補撤回。原記録を保持し、所有試験だけを停止する。", + "active": 0, + "cleanup_status": "worker報告は自己processゼロ。rootの原記録監査は進行中。" }, "gui_observation": { "status": "blocked", @@ -114,5 +131,6 @@ } }, "repair_commit": "bce891b2cbf3a77afa7ab738e19f965e7ac9808c", - "withdrawal_evidence": "rag/parent-delivery/codex-probe-rejoin-diagnosis.json" + "withdrawal_evidence": "rag/parent-delivery/codex-probe-rejoin-diagnosis.json", + "controller_observation_limit_ms": 1500 } diff --git a/rag/parent-delivery/sources/node-v24.20.0-libuv-win-fs.c.txt b/rag/parent-delivery/sources/node-v24.20.0-libuv-win-fs.c.txt new file mode 100644 index 00000000..4092de0a --- /dev/null +++ b/rag/parent-delivery/sources/node-v24.20.0-libuv-win-fs.c.txt @@ -0,0 +1,3811 @@ +/* Copyright Joyent, Inc. and other Node contributors. All rights reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "uv.h" + +/* requires , included via "uv.h" above, but needs to + be included before our "winapi.h", included via "internal.h" below. */ +#include + +#include "internal.h" +#include "req-inl.h" +#include "handle-inl.h" +#include "fs-fd-hash-inl.h" + + +#define UV_FS_FREE_PATHS 0x0002 +#define UV_FS_FREE_PTR 0x0008 +#define UV_FS_CLEANEDUP 0x0010 + +#ifndef FILE_DISPOSITION_DELETE +#define FILE_DISPOSITION_DELETE 0x0001 +#endif /* FILE_DISPOSITION_DELETE */ + +#ifndef FILE_DISPOSITION_POSIX_SEMANTICS +#define FILE_DISPOSITION_POSIX_SEMANTICS 0x0002 +#endif /* FILE_DISPOSITION_POSIX_SEMANTICS */ + +#ifndef FILE_DISPOSITION_IGNORE_READONLY_ATTRIBUTE +#define FILE_DISPOSITION_IGNORE_READONLY_ATTRIBUTE 0x0010 +#endif /* FILE_DISPOSITION_IGNORE_READONLY_ATTRIBUTE */ + +NTSTATUS uv__RtlUnicodeStringInit( + PUNICODE_STRING DestinationString, + PWSTR SourceString, + size_t SourceStringLen +) { + if (SourceStringLen > 0x7FFF) + return STATUS_INVALID_PARAMETER; + DestinationString->MaximumLength = DestinationString->Length = + SourceStringLen * sizeof(SourceString[0]); + DestinationString->Buffer = SourceString; + return STATUS_SUCCESS; +} + +#define INIT(subtype) \ + do { \ + if (req == NULL) \ + return UV_EINVAL; \ + uv__fs_req_init(loop, req, subtype, cb); \ + } \ + while (0) + +#define POST \ + do { \ + if (cb != NULL) { \ + uv__req_register(loop); \ + uv__work_submit(loop, \ + &req->work_req, \ + UV__WORK_FAST_IO, \ + uv__fs_work, \ + uv__fs_done); \ + return 0; \ + } else { \ + uv__fs_work(&req->work_req); \ + return req->result; \ + } \ + } \ + while (0) + +#define SET_REQ_RESULT(req, result_value) \ + do { \ + req->result = (result_value); \ + assert(req->result != -1); \ + } while (0) + +#define SET_REQ_WIN32_ERROR(req, sys_errno) \ + do { \ + req->sys_errno_ = (sys_errno); \ + req->result = uv_translate_sys_error(req->sys_errno_); \ + } while (0) + +#define SET_REQ_UV_ERROR(req, uv_errno, sys_errno) \ + do { \ + req->result = (uv_errno); \ + req->sys_errno_ = (sys_errno); \ + } while (0) + +#define VERIFY_FD(fd, req) \ + if (fd == -1) { \ + req->result = UV_EBADF; \ + req->sys_errno_ = ERROR_INVALID_HANDLE; \ + return; \ + } + +#define NSEC_PER_TICK 100 +#define TICKS_PER_SEC ((int64_t) 1e9 / NSEC_PER_TICK) +static const int64_t WIN_TO_UNIX_TICK_OFFSET = 11644473600 * TICKS_PER_SEC; + +static void uv__filetime_to_timespec(uv_timespec_t *ts, int64_t filetime) { + filetime -= WIN_TO_UNIX_TICK_OFFSET; + ts->tv_sec = filetime / TICKS_PER_SEC; + ts->tv_nsec = (filetime % TICKS_PER_SEC) * NSEC_PER_TICK; + if (ts->tv_nsec < 0) { + ts->tv_sec -= 1; + ts->tv_nsec += 1e9; + } +} + +#define TIME_T_TO_FILETIME(time, filetime_ptr) \ + do { \ + int64_t bigtime = ((time) * TICKS_PER_SEC + WIN_TO_UNIX_TICK_OFFSET); \ + (filetime_ptr)->dwLowDateTime = (uint64_t) bigtime & 0xFFFFFFFF; \ + (filetime_ptr)->dwHighDateTime = (uint64_t) bigtime >> 32; \ + } while(0) + +#define IS_SLASH(c) ((c) == L'\\' || (c) == L'/') +#define IS_LETTER(c) (((c) >= L'a' && (c) <= L'z') || \ + ((c) >= L'A' && (c) <= L'Z')) + +#define MIN(a,b) (((a) < (b)) ? (a) : (b)) + +const WCHAR JUNCTION_PREFIX[] = L"\\??\\"; +const WCHAR JUNCTION_PREFIX_LEN = 4; + +const WCHAR LONG_PATH_PREFIX[] = L"\\\\?\\"; +const WCHAR LONG_PATH_PREFIX_LEN = 4; + +const WCHAR UNC_PATH_PREFIX[] = L"\\\\?\\UNC\\"; +const WCHAR UNC_PATH_PREFIX_LEN = 8; + +static int uv__file_symlink_usermode_flag = SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE; + +static DWORD uv__allocation_granularity; + +typedef enum { + FS__STAT_PATH_SUCCESS, + FS__STAT_PATH_ERROR, + FS__STAT_PATH_TRY_SLOW +} fs__stat_path_return_t; + +static void fs__stat_assign_statbuf_null(uv_stat_t* statbuf); +static void fs__stat_assign_statbuf(uv_stat_t* statbuf, + FILE_STAT_BASIC_INFORMATION stat_info, + int do_lstat); + + +void uv__fs_init(void) { + SYSTEM_INFO system_info; + + GetSystemInfo(&system_info); + uv__allocation_granularity = system_info.dwAllocationGranularity; + + uv__fd_hash_init(); +} + + +static int fs__readlink_handle(HANDLE handle, + char** target_ptr, + size_t* target_len_ptr) { + char buffer[MAXIMUM_REPARSE_DATA_BUFFER_SIZE]; + REPARSE_DATA_BUFFER* reparse_data = (REPARSE_DATA_BUFFER*) buffer; + WCHAR* w_target; + DWORD w_target_len; + DWORD bytes; + size_t i; + size_t len; + + if (!DeviceIoControl(handle, + FSCTL_GET_REPARSE_POINT, + NULL, + 0, + buffer, + sizeof buffer, + &bytes, + NULL)) { + return -1; + } + + if (reparse_data->ReparseTag == IO_REPARSE_TAG_SYMLINK) { + /* Real symlink */ + w_target = reparse_data->SymbolicLinkReparseBuffer.PathBuffer + + (reparse_data->SymbolicLinkReparseBuffer.SubstituteNameOffset / + sizeof(WCHAR)); + w_target_len = + reparse_data->SymbolicLinkReparseBuffer.SubstituteNameLength / + sizeof(WCHAR); + + /* Real symlinks can contain pretty much everything, but the only thing we + * really care about is undoing the implicit conversion to an NT namespaced + * path that CreateSymbolicLink will perform on absolute paths. If the path + * is win32-namespaced then the user must have explicitly made it so, and + * we better just return the unmodified reparse data. */ + if (w_target_len >= 4 && + w_target[0] == L'\\' && + w_target[1] == L'?' && + w_target[2] == L'?' && + w_target[3] == L'\\') { + /* Starts with \??\ */ + if (w_target_len >= 6 && + ((w_target[4] >= L'A' && w_target[4] <= L'Z') || + (w_target[4] >= L'a' && w_target[4] <= L'z')) && + w_target[5] == L':' && + (w_target_len == 6 || w_target[6] == L'\\')) { + /* \??\:\ */ + w_target += 4; + w_target_len -= 4; + + } else if (w_target_len >= 8 && + (w_target[4] == L'U' || w_target[4] == L'u') && + (w_target[5] == L'N' || w_target[5] == L'n') && + (w_target[6] == L'C' || w_target[6] == L'c') && + w_target[7] == L'\\') { + /* \??\UNC\\\ - make sure the final path looks like + * \\\\ */ + w_target += 6; + w_target[0] = L'\\'; + w_target_len -= 6; + } + } + + } else if (reparse_data->ReparseTag == IO_REPARSE_TAG_LX_SYMLINK) { + /* Real (Linux) symlink */ + char* buffer; + char* target; + size_t target_len; + + target_len = (reparse_data->ReparseDataLength - + sizeof(ULONG)); /* Version field */ + buffer = (char*) reparse_data->LinuxSymbolicLinkReparseBuffer.PathBuffer; + + if (target_len_ptr != NULL) { + *target_len_ptr = target_len; + } + + if (target_ptr != NULL) { + assert(*target_ptr == NULL); + target = uv__malloc(target_len + 1); + if (target == NULL) { + return UV_ENOMEM; + } + memcpy(target, buffer, target_len); + target[target_len] = '\0'; + *target_ptr = target; + } + return 0; + + } else if (reparse_data->ReparseTag == IO_REPARSE_TAG_MOUNT_POINT) { + /* Junction. */ + w_target = reparse_data->MountPointReparseBuffer.PathBuffer + + (reparse_data->MountPointReparseBuffer.SubstituteNameOffset / + sizeof(WCHAR)); + w_target_len = reparse_data->MountPointReparseBuffer.SubstituteNameLength / + sizeof(WCHAR); + + /* Only treat junctions that look like \??\:\ as symlink. Junctions + * can also be used as mount points, like \??\Volume{}, but that's + * confusing for programs since they wouldn't be able to actually + * understand such a path when returned by uv_readlink(). UNC paths are + * never valid for junctions so we don't care about them. */ + if (!(w_target_len >= 6 && + w_target[0] == L'\\' && + w_target[1] == L'?' && + w_target[2] == L'?' && + w_target[3] == L'\\' && + ((w_target[4] >= L'A' && w_target[4] <= L'Z') || + (w_target[4] >= L'a' && w_target[4] <= L'z')) && + w_target[5] == L':' && + (w_target_len == 6 || w_target[6] == L'\\'))) { + SetLastError(ERROR_SYMLINK_NOT_SUPPORTED); + return -1; + } + + /* Remove leading \??\ */ + w_target += 4; + w_target_len -= 4; + + } else if (reparse_data->ReparseTag == IO_REPARSE_TAG_APPEXECLINK) { + /* String #3 in the list has the target filename. */ + if (reparse_data->AppExecLinkReparseBuffer.StringCount < 3) { + SetLastError(ERROR_SYMLINK_NOT_SUPPORTED); + return -1; + } + w_target = reparse_data->AppExecLinkReparseBuffer.StringList; + /* The StringList buffer contains a list of strings separated by "\0", */ + /* with "\0\0" terminating the list. Move to the 3rd string in the list: */ + for (i = 0; i < 2; ++i) { + len = wcslen(w_target); + if (len == 0) { + SetLastError(ERROR_SYMLINK_NOT_SUPPORTED); + return -1; + } + w_target += len + 1; + } + w_target_len = wcslen(w_target); + if (w_target_len == 0) { + SetLastError(ERROR_SYMLINK_NOT_SUPPORTED); + return -1; + } + /* Make sure it is an absolute path. */ + if (!(w_target_len >= 3 && + ((w_target[0] >= L'a' && w_target[0] <= L'z') || + (w_target[0] >= L'A' && w_target[0] <= L'Z')) && + w_target[1] == L':' && + w_target[2] == L'\\')) { + SetLastError(ERROR_SYMLINK_NOT_SUPPORTED); + return -1; + } + + } else { + /* Reparse tag does not indicate a symlink. */ + SetLastError(ERROR_SYMLINK_NOT_SUPPORTED); + return -1; + } + + assert(target_ptr == NULL || *target_ptr == NULL); + return uv_utf16_to_wtf8(w_target, w_target_len, target_ptr, target_len_ptr); +} + + +static int fs__capture_path(uv_fs_t* req, + const char* path, + const char* new_path, + const int copy_path) { + WCHAR* buf; + WCHAR* pos; + size_t buf_sz = 0; + size_t path_len = 0; + ssize_t pathw_len = 0; + ssize_t new_pathw_len = 0; + + /* new_path can only be set if path is also set. */ + assert(new_path == NULL || path != NULL); + + if (path != NULL) { + pathw_len = uv_wtf8_length_as_utf16(path); + if (pathw_len < 0) + return ERROR_INVALID_NAME; + buf_sz += pathw_len * sizeof(WCHAR); + } + + if (path != NULL && copy_path) { + path_len = 1 + strlen(path); + buf_sz += path_len; + } + + if (new_path != NULL) { + new_pathw_len = uv_wtf8_length_as_utf16(new_path); + if (new_pathw_len < 0) + return ERROR_INVALID_NAME; + buf_sz += new_pathw_len * sizeof(WCHAR); + } + + + if (buf_sz == 0) { + req->file.pathw = NULL; + req->fs.info.new_pathw = NULL; + req->path = NULL; + return 0; + } + + buf = uv__malloc(buf_sz); + if (buf == NULL) { + return ERROR_OUTOFMEMORY; + } + + pos = buf; + + if (path != NULL) { + uv_wtf8_to_utf16(path, pos, pathw_len); + req->file.pathw = pos; + pos += pathw_len; + } else { + req->file.pathw = NULL; + } + + if (new_path != NULL) { + uv_wtf8_to_utf16(new_path, pos, new_pathw_len); + req->fs.info.new_pathw = pos; + pos += new_pathw_len; + } else { + req->fs.info.new_pathw = NULL; + } + + req->path = path; + if (path != NULL && copy_path) { + memcpy(pos, path, path_len); + assert(path_len == buf_sz - (pos - buf) * sizeof(WCHAR)); + req->path = (char*) pos; + } + + req->flags |= UV_FS_FREE_PATHS; + + return 0; +} + + +static void uv__fs_req_init(uv_loop_t* loop, + uv_fs_t* req, + uv_fs_type fs_type, + const uv_fs_cb cb) { + uv__once_init(); + UV_REQ_INIT(req, UV_FS); + req->loop = loop; + req->flags = 0; + req->fs_type = fs_type; + req->sys_errno_ = 0; + req->result = 0; + req->ptr = NULL; + req->path = NULL; + req->cb = cb; + memset(&req->fs, 0, sizeof(req->fs)); +} + + +void fs__open(uv_fs_t* req) { + DWORD access; + DWORD share; + DWORD disposition; + DWORD attributes = 0; + HANDLE file; + int fd, current_umask; + int flags = req->fs.info.file_flags; + struct uv__fd_info_s fd_info; + + /* Adjust flags to be compatible with the memory file mapping. Save the + * original flags to emulate the correct behavior. */ + if (flags & UV_FS_O_FILEMAP) { + fd_info.flags = flags; + fd_info.current_pos.QuadPart = 0; + + if ((flags & (UV_FS_O_RDONLY | UV_FS_O_WRONLY | UV_FS_O_RDWR)) == + UV_FS_O_WRONLY) { + /* CreateFileMapping always needs read access */ + flags = (flags & ~UV_FS_O_WRONLY) | UV_FS_O_RDWR; + } + + if (flags & UV_FS_O_APPEND) { + /* Clear the append flag and ensure RDRW mode */ + flags &= ~UV_FS_O_APPEND; + flags &= ~(UV_FS_O_RDONLY | UV_FS_O_WRONLY | UV_FS_O_RDWR); + flags |= UV_FS_O_RDWR; + } + } + + /* Obtain the active umask. umask() never fails and returns the previous + * umask. */ + current_umask = _umask(0); + _umask(current_umask); + + /* convert flags and mode to CreateFile parameters */ + switch (flags & (UV_FS_O_RDONLY | UV_FS_O_WRONLY | UV_FS_O_RDWR)) { + case UV_FS_O_RDONLY: + access = FILE_GENERIC_READ; + break; + case UV_FS_O_WRONLY: + access = FILE_GENERIC_WRITE; + break; + case UV_FS_O_RDWR: + access = FILE_GENERIC_READ | FILE_GENERIC_WRITE; + break; + default: + goto einval; + } + + if (flags & UV_FS_O_APPEND) { + access &= ~FILE_WRITE_DATA; + access |= FILE_APPEND_DATA; + } + + /* + * Here is where we deviate significantly from what CRT's _open() + * does. We indiscriminately use all the sharing modes, to match + * UNIX semantics. In particular, this ensures that the file can + * be deleted even whilst it's open, fixing issue + * https://github.com/nodejs/node-v0.x-archive/issues/1449. + * We still support exclusive sharing mode, since it is necessary + * for opening raw block devices, otherwise Windows will prevent + * any attempt to write past the master boot record. + */ + if (flags & UV_FS_O_EXLOCK) { + share = 0; + } else { + share = FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE; + } + + switch (flags & (UV_FS_O_CREAT | UV_FS_O_EXCL | UV_FS_O_TRUNC)) { + case 0: + case UV_FS_O_EXCL: + disposition = OPEN_EXISTING; + break; + case UV_FS_O_CREAT: + disposition = OPEN_ALWAYS; + break; + case UV_FS_O_CREAT | UV_FS_O_EXCL: + case UV_FS_O_CREAT | UV_FS_O_TRUNC | UV_FS_O_EXCL: + disposition = CREATE_NEW; + break; + case UV_FS_O_TRUNC: + case UV_FS_O_TRUNC | UV_FS_O_EXCL: + disposition = TRUNCATE_EXISTING; + break; + case UV_FS_O_CREAT | UV_FS_O_TRUNC: + disposition = CREATE_ALWAYS; + break; + default: + goto einval; + } + + attributes |= FILE_ATTRIBUTE_NORMAL; + if (flags & UV_FS_O_CREAT) { + if (!((req->fs.info.mode & ~current_umask) & _S_IWRITE)) { + attributes |= FILE_ATTRIBUTE_READONLY; + } + } + + if (flags & UV_FS_O_TEMPORARY ) { + attributes |= FILE_FLAG_DELETE_ON_CLOSE | FILE_ATTRIBUTE_TEMPORARY; + access |= DELETE; + } + + if (flags & UV_FS_O_SHORT_LIVED) { + attributes |= FILE_ATTRIBUTE_TEMPORARY; + } + + switch (flags & (UV_FS_O_SEQUENTIAL | UV_FS_O_RANDOM)) { + case 0: + break; + case UV_FS_O_SEQUENTIAL: + attributes |= FILE_FLAG_SEQUENTIAL_SCAN; + break; + case UV_FS_O_RANDOM: + attributes |= FILE_FLAG_RANDOM_ACCESS; + break; + default: + goto einval; + } + + if (flags & UV_FS_O_DIRECT) { + /* + * FILE_APPEND_DATA and FILE_FLAG_NO_BUFFERING are mutually exclusive. + * Windows returns 87, ERROR_INVALID_PARAMETER if these are combined. + * + * FILE_APPEND_DATA is included in FILE_GENERIC_WRITE: + * + * FILE_GENERIC_WRITE = STANDARD_RIGHTS_WRITE | + * FILE_WRITE_DATA | + * FILE_WRITE_ATTRIBUTES | + * FILE_WRITE_EA | + * FILE_APPEND_DATA | + * SYNCHRONIZE + * + * Note: Appends are also permitted by FILE_WRITE_DATA. + * + * In order for direct writes and direct appends to succeed, we therefore + * exclude FILE_APPEND_DATA if FILE_WRITE_DATA is specified, and otherwise + * fail if the user's sole permission is a direct append, since this + * particular combination is invalid. + */ + if (access & FILE_APPEND_DATA) { + if (access & FILE_WRITE_DATA) { + access &= ~FILE_APPEND_DATA; + } else { + goto einval; + } + } + attributes |= FILE_FLAG_NO_BUFFERING; + } + + switch (flags & (UV_FS_O_DSYNC | UV_FS_O_SYNC)) { + case 0: + break; + case UV_FS_O_DSYNC: + case UV_FS_O_SYNC: + attributes |= FILE_FLAG_WRITE_THROUGH; + break; + default: + goto einval; + } + + /* Setting this flag makes it possible to open a directory. */ + attributes |= FILE_FLAG_BACKUP_SEMANTICS; + + file = CreateFileW(req->file.pathw, + access, + share, + NULL, + disposition, + attributes, + NULL); + if (file == INVALID_HANDLE_VALUE) { + DWORD error = GetLastError(); + if (error == ERROR_FILE_EXISTS && (flags & UV_FS_O_CREAT) && + !(flags & UV_FS_O_EXCL)) { + /* Special case: when ERROR_FILE_EXISTS happens and UV_FS_O_CREAT was + * specified, it means the path referred to a directory. */ + SET_REQ_UV_ERROR(req, UV_EISDIR, error); + } else { + SET_REQ_WIN32_ERROR(req, GetLastError()); + } + return; + } + + fd = _open_osfhandle((intptr_t) file, flags); + if (fd < 0) { + /* The only known failure mode for _open_osfhandle() is EMFILE, in which + * case GetLastError() will return zero. However we'll try to handle other + * errors as well, should they ever occur. + */ + if (errno == EMFILE) + SET_REQ_UV_ERROR(req, UV_EMFILE, ERROR_TOO_MANY_OPEN_FILES); + else if (GetLastError() != ERROR_SUCCESS) + SET_REQ_WIN32_ERROR(req, GetLastError()); + else + SET_REQ_WIN32_ERROR(req, (DWORD) UV_UNKNOWN); + CloseHandle(file); + return; + } + + if (flags & UV_FS_O_FILEMAP) { + FILE_STANDARD_INFO file_info; + if (!GetFileInformationByHandleEx(file, + FileStandardInfo, + &file_info, + sizeof file_info)) { + SET_REQ_WIN32_ERROR(req, GetLastError()); + CloseHandle(file); + return; + } + fd_info.is_directory = file_info.Directory; + + if (fd_info.is_directory) { + fd_info.size.QuadPart = 0; + fd_info.mapping = INVALID_HANDLE_VALUE; + } else { + if (!GetFileSizeEx(file, &fd_info.size)) { + SET_REQ_WIN32_ERROR(req, GetLastError()); + CloseHandle(file); + return; + } + + if (fd_info.size.QuadPart == 0) { + fd_info.mapping = INVALID_HANDLE_VALUE; + } else { + DWORD flProtect = (fd_info.flags & (UV_FS_O_RDONLY | UV_FS_O_WRONLY | + UV_FS_O_RDWR)) == UV_FS_O_RDONLY ? PAGE_READONLY : PAGE_READWRITE; + fd_info.mapping = CreateFileMapping(file, + NULL, + flProtect, + fd_info.size.HighPart, + fd_info.size.LowPart, + NULL); + if (fd_info.mapping == NULL) { + SET_REQ_WIN32_ERROR(req, GetLastError()); + CloseHandle(file); + return; + } + } + } + + uv__fd_hash_add(fd, &fd_info); + } + + SET_REQ_RESULT(req, fd); + return; + + einval: + SET_REQ_UV_ERROR(req, UV_EINVAL, ERROR_INVALID_PARAMETER); +} + +void fs__close(uv_fs_t* req) { + int fd = req->file.fd; + int result; + struct uv__fd_info_s fd_info; + + VERIFY_FD(fd, req); + + if (uv__fd_hash_remove(fd, &fd_info)) { + if (fd_info.mapping != INVALID_HANDLE_VALUE) { + CloseHandle(fd_info.mapping); + } + } + + if (fd > 2) + result = _close(fd); + else + result = 0; + + /* _close doesn't set _doserrno on failure, but it does always set errno + * to EBADF on failure. + */ + if (result == -1) { + assert(errno == EBADF); + SET_REQ_UV_ERROR(req, UV_EBADF, ERROR_INVALID_HANDLE); + } else { + SET_REQ_RESULT(req, 0); + } +} + + +LONG fs__filemap_ex_filter(LONG excode, PEXCEPTION_POINTERS pep, + int* perror) { + if (excode != (LONG)EXCEPTION_IN_PAGE_ERROR) { + return EXCEPTION_CONTINUE_SEARCH; + } + + assert(perror != NULL); + if (pep != NULL && pep->ExceptionRecord != NULL && + pep->ExceptionRecord->NumberParameters >= 3) { + NTSTATUS status = (NTSTATUS)pep->ExceptionRecord->ExceptionInformation[3]; + *perror = pRtlNtStatusToDosError(status); + if (*perror != ERROR_SUCCESS) { + return EXCEPTION_EXECUTE_HANDLER; + } + } + *perror = UV_UNKNOWN; + return EXCEPTION_EXECUTE_HANDLER; +} + + +void fs__read_filemap(uv_fs_t* req, struct uv__fd_info_s* fd_info) { + int fd = req->file.fd; /* VERIFY_FD done in fs__read */ + int rw_flags = fd_info->flags & + (UV_FS_O_RDONLY | UV_FS_O_WRONLY | UV_FS_O_RDWR); + size_t read_size, done_read; + unsigned int index; + LARGE_INTEGER pos, end_pos; + size_t view_offset; + LARGE_INTEGER view_base; + void* view; + + if (rw_flags == UV_FS_O_WRONLY) { + SET_REQ_WIN32_ERROR(req, ERROR_INVALID_FLAGS); + return; + } + if (fd_info->is_directory) { + SET_REQ_WIN32_ERROR(req, ERROR_INVALID_FUNCTION); + return; + } + + if (req->fs.info.offset == -1) { + pos = fd_info->current_pos; + } else { + pos.QuadPart = req->fs.info.offset; + } + + /* Make sure we wont read past EOF. */ + if (pos.QuadPart >= fd_info->size.QuadPart) { + SET_REQ_RESULT(req, 0); + return; + } + + read_size = 0; + for (index = 0; index < req->fs.info.nbufs; ++index) { + read_size += req->fs.info.bufs[index].len; + } + read_size = (size_t) MIN((LONGLONG) read_size, + fd_info->size.QuadPart - pos.QuadPart); + if (read_size == 0) { + SET_REQ_RESULT(req, 0); + return; + } + + end_pos.QuadPart = pos.QuadPart + read_size; + + view_offset = pos.QuadPart % uv__allocation_granularity; + view_base.QuadPart = pos.QuadPart - view_offset; + view = MapViewOfFile(fd_info->mapping, + FILE_MAP_READ, + view_base.HighPart, + view_base.LowPart, + view_offset + read_size); + if (view == NULL) { + SET_REQ_WIN32_ERROR(req, GetLastError()); + return; + } + + done_read = 0; + for (index = 0; + index < req->fs.info.nbufs && done_read < read_size; + ++index) { + size_t this_read_size = MIN(req->fs.info.bufs[index].len, + read_size - done_read); +#ifdef _MSC_VER + int err = 0; + __try { +#endif + memcpy(req->fs.info.bufs[index].base, + (char*)view + view_offset + done_read, + this_read_size); +#ifdef _MSC_VER + } + __except (fs__filemap_ex_filter(GetExceptionCode(), + GetExceptionInformation(), &err)) { + SET_REQ_WIN32_ERROR(req, err); + UnmapViewOfFile(view); + return; + } +#endif + done_read += this_read_size; + } + assert(done_read == read_size); + + if (!UnmapViewOfFile(view)) { + SET_REQ_WIN32_ERROR(req, GetLastError()); + return; + } + + if (req->fs.info.offset == -1) { + fd_info->current_pos = end_pos; + uv__fd_hash_add(fd, fd_info); + } + + SET_REQ_RESULT(req, read_size); + return; +} + +void fs__read(uv_fs_t* req) { + int fd = req->file.fd; + int64_t offset = req->fs.info.offset; + HANDLE handle; + OVERLAPPED overlapped, *overlapped_ptr; + LARGE_INTEGER offset_; + DWORD bytes; + DWORD error; + int result; + unsigned int index; + LARGE_INTEGER original_position; + LARGE_INTEGER zero_offset; + int restore_position; + struct uv__fd_info_s fd_info; + + VERIFY_FD(fd, req); + + if (uv__fd_hash_get(fd, &fd_info)) { + fs__read_filemap(req, &fd_info); + return; + } + + zero_offset.QuadPart = 0; + restore_position = 0; + handle = uv__get_osfhandle(fd); + + if (handle == INVALID_HANDLE_VALUE) { + SET_REQ_WIN32_ERROR(req, ERROR_INVALID_HANDLE); + return; + } + + if (offset != -1) { + memset(&overlapped, 0, sizeof overlapped); + overlapped_ptr = &overlapped; + if (SetFilePointerEx(handle, zero_offset, &original_position, + FILE_CURRENT)) { + restore_position = 1; + } + } else { + overlapped_ptr = NULL; + } + + index = 0; + bytes = 0; + do { + DWORD incremental_bytes; + + if (offset != -1) { + offset_.QuadPart = offset + bytes; + overlapped.Offset = offset_.LowPart; + overlapped.OffsetHigh = offset_.HighPart; + } + + result = ReadFile(handle, + req->fs.info.bufs[index].base, + req->fs.info.bufs[index].len, + &incremental_bytes, + overlapped_ptr); + bytes += incremental_bytes; + ++index; + } while (result && index < req->fs.info.nbufs); + + if (restore_position) + SetFilePointerEx(handle, original_position, NULL, FILE_BEGIN); + + if (result || bytes > 0) { + SET_REQ_RESULT(req, bytes); + } else { + error = GetLastError(); + if (error == ERROR_ACCESS_DENIED) { + error = ERROR_INVALID_FLAGS; + } + + if (error == ERROR_HANDLE_EOF || error == ERROR_BROKEN_PIPE) { + SET_REQ_RESULT(req, bytes); + } else { + SET_REQ_WIN32_ERROR(req, error); + } + } +} + + +void fs__write_filemap(uv_fs_t* req, HANDLE file, + struct uv__fd_info_s* fd_info) { + int fd = req->file.fd; /* VERIFY_FD done in fs__write */ + int force_append = fd_info->flags & UV_FS_O_APPEND; + int rw_flags = fd_info->flags & + (UV_FS_O_RDONLY | UV_FS_O_WRONLY | UV_FS_O_RDWR); + size_t write_size, done_write; + unsigned int index; + LARGE_INTEGER pos, end_pos; + size_t view_offset; + LARGE_INTEGER view_base; + void* view; + FILETIME ft; + + if (rw_flags == UV_FS_O_RDONLY) { + SET_REQ_WIN32_ERROR(req, ERROR_INVALID_FLAGS); + return; + } + if (fd_info->is_directory) { + SET_REQ_WIN32_ERROR(req, ERROR_INVALID_FUNCTION); + return; + } + + write_size = 0; + for (index = 0; index < req->fs.info.nbufs; ++index) { + write_size += req->fs.info.bufs[index].len; + } + + if (write_size == 0) { + SET_REQ_RESULT(req, 0); + return; + } + + if (force_append) { + pos = fd_info->size; + } else if (req->fs.info.offset == -1) { + pos = fd_info->current_pos; + } else { + pos.QuadPart = req->fs.info.offset; + } + + end_pos.QuadPart = pos.QuadPart + write_size; + + /* Recreate the mapping to enlarge the file if needed */ + if (end_pos.QuadPart > fd_info->size.QuadPart) { + if (fd_info->mapping != INVALID_HANDLE_VALUE) { + CloseHandle(fd_info->mapping); + } + + fd_info->mapping = CreateFileMapping(file, + NULL, + PAGE_READWRITE, + end_pos.HighPart, + end_pos.LowPart, + NULL); + if (fd_info->mapping == NULL) { + SET_REQ_WIN32_ERROR(req, GetLastError()); + CloseHandle(file); + fd_info->mapping = INVALID_HANDLE_VALUE; + fd_info->size.QuadPart = 0; + fd_info->current_pos.QuadPart = 0; + uv__fd_hash_add(fd, fd_info); + return; + } + + fd_info->size = end_pos; + uv__fd_hash_add(fd, fd_info); + } + + view_offset = pos.QuadPart % uv__allocation_granularity; + view_base.QuadPart = pos.QuadPart - view_offset; + view = MapViewOfFile(fd_info->mapping, + FILE_MAP_WRITE, + view_base.HighPart, + view_base.LowPart, + view_offset + write_size); + if (view == NULL) { + SET_REQ_WIN32_ERROR(req, GetLastError()); + return; + } + + done_write = 0; + for (index = 0; index < req->fs.info.nbufs; ++index) { +#ifdef _MSC_VER + int err = 0; + __try { +#endif + memcpy((char*)view + view_offset + done_write, + req->fs.info.bufs[index].base, + req->fs.info.bufs[index].len); +#ifdef _MSC_VER + } + __except (fs__filemap_ex_filter(GetExceptionCode(), + GetExceptionInformation(), &err)) { + SET_REQ_WIN32_ERROR(req, err); + UnmapViewOfFile(view); + return; + } +#endif + done_write += req->fs.info.bufs[index].len; + } + assert(done_write == write_size); + + if (!FlushViewOfFile(view, 0)) { + SET_REQ_WIN32_ERROR(req, GetLastError()); + UnmapViewOfFile(view); + return; + } + if (!UnmapViewOfFile(view)) { + SET_REQ_WIN32_ERROR(req, GetLastError()); + return; + } + + if (req->fs.info.offset == -1) { + fd_info->current_pos = end_pos; + uv__fd_hash_add(fd, fd_info); + } + + GetSystemTimeAsFileTime(&ft); + SetFileTime(file, NULL, NULL, &ft); + + SET_REQ_RESULT(req, done_write); +} + +void fs__write(uv_fs_t* req) { + int fd = req->file.fd; + int64_t offset = req->fs.info.offset; + HANDLE handle; + OVERLAPPED overlapped, *overlapped_ptr; + LARGE_INTEGER offset_; + DWORD bytes; + DWORD error; + int result; + unsigned int index; + LARGE_INTEGER original_position; + LARGE_INTEGER zero_offset; + int restore_position; + struct uv__fd_info_s fd_info; + + VERIFY_FD(fd, req); + + zero_offset.QuadPart = 0; + restore_position = 0; + handle = uv__get_osfhandle(fd); + if (handle == INVALID_HANDLE_VALUE) { + SET_REQ_WIN32_ERROR(req, ERROR_INVALID_HANDLE); + return; + } + + if (uv__fd_hash_get(fd, &fd_info)) { + fs__write_filemap(req, handle, &fd_info); + return; + } + + if (offset != -1) { + memset(&overlapped, 0, sizeof overlapped); + overlapped_ptr = &overlapped; + if (SetFilePointerEx(handle, zero_offset, &original_position, + FILE_CURRENT)) { + restore_position = 1; + } + } else { + overlapped_ptr = NULL; + } + + index = 0; + bytes = 0; + do { + DWORD incremental_bytes; + + if (offset != -1) { + offset_.QuadPart = offset + bytes; + overlapped.Offset = offset_.LowPart; + overlapped.OffsetHigh = offset_.HighPart; + } + + result = WriteFile(handle, + req->fs.info.bufs[index].base, + req->fs.info.bufs[index].len, + &incremental_bytes, + overlapped_ptr); + bytes += incremental_bytes; + ++index; + } while (result && index < req->fs.info.nbufs); + + if (restore_position) + SetFilePointerEx(handle, original_position, NULL, FILE_BEGIN); + + if (result || bytes > 0) { + SET_REQ_RESULT(req, bytes); + } else { + error = GetLastError(); + + if (error == ERROR_ACCESS_DENIED) { + error = ERROR_INVALID_FLAGS; + } + + SET_REQ_UV_ERROR(req, uv_translate_write_sys_error(error), error); + } +} + + +static void fs__unlink_rmdir(uv_fs_t* req, BOOL isrmdir) { + const WCHAR* pathw = req->file.pathw; + HANDLE handle; + FILE_BASIC_INFO info; + FILE_DISPOSITION_INFORMATION disposition; + FILE_DISPOSITION_INFORMATION_EX disposition_ex; + IO_STATUS_BLOCK iosb; + NTSTATUS status; + DWORD error; + + handle = CreateFileW(pathw, + FILE_READ_ATTRIBUTES | DELETE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + NULL, + OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, + NULL); + + if (handle == INVALID_HANDLE_VALUE) { + SET_REQ_WIN32_ERROR(req, GetLastError()); + return; + } + + if (!GetFileInformationByHandleEx(handle, FileBasicInfo, &info, sizeof info)) { + SET_REQ_WIN32_ERROR(req, GetLastError()); + CloseHandle(handle); + return; + } + + if (isrmdir && !(info.FileAttributes & FILE_ATTRIBUTE_DIRECTORY)) { + /* Error if we're in rmdir mode but it is not a dir. + * TODO: change it to UV_NOTDIR in v2. */ + SET_REQ_UV_ERROR(req, UV_ENOENT, ERROR_DIRECTORY); + CloseHandle(handle); + return; + } + + if (!isrmdir && (info.FileAttributes & FILE_ATTRIBUTE_DIRECTORY)) { + /* If not explicitly allowed, do not allow deletion of directories, unless + * it is a symlink. When the path refers to a non-symlink directory, report + * EPERM as mandated by POSIX.1. */ + + /* Check if it is a reparse point. If it's not, it's a normal directory. */ + if (!(info.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)) { + SET_REQ_WIN32_ERROR(req, ERROR_ACCESS_DENIED); + CloseHandle(handle); + return; + } + + /* Read the reparse point and check if it is a valid symlink. If not, don't + * unlink. */ + if (fs__readlink_handle(handle, NULL, NULL) < 0) { + error = GetLastError(); + if (error == ERROR_SYMLINK_NOT_SUPPORTED) + error = ERROR_ACCESS_DENIED; + SET_REQ_WIN32_ERROR(req, error); + CloseHandle(handle); + return; + } + } + + /* Try posix delete first */ + disposition_ex.Flags = FILE_DISPOSITION_DELETE | FILE_DISPOSITION_POSIX_SEMANTICS | + FILE_DISPOSITION_IGNORE_READONLY_ATTRIBUTE; + + status = pNtSetInformationFile(handle, + &iosb, + &disposition_ex, + sizeof disposition_ex, + FileDispositionInformationEx); + if (NT_SUCCESS(status)) { + SET_REQ_SUCCESS(req); + } else { + /* If status == STATUS_CANNOT_DELETE here, given we set + * FILE_DISPOSITION_IGNORE_READONLY_ATTRIBUTE, STATUS_CANNOT_DELETE can only mean + * that there is an existing mapped view to the file, preventing delete. + * STATUS_CANNOT_DELETE maps to UV_EACCES so it's not specifically worth handling */ + error = pRtlNtStatusToDosError(status); + if (error == ERROR_NOT_SUPPORTED /* filesystem does not support posix deletion */ || + error == ERROR_INVALID_PARAMETER /* pre Windows 10 error */ || + error == ERROR_INVALID_FUNCTION /* pre Windows 10 1607 error */) { + /* posix delete not supported so try fallback */ + if (info.FileAttributes & FILE_ATTRIBUTE_READONLY) { + /* Remove read-only attribute */ + FILE_BASIC_INFORMATION basic = { 0 }; + + /* We opened the handle above without FILE_WRITE_ATTRIBUTES access, which + * is not required in the happy path. On windows, it would probably + * be ok to ask for them anyway, but Wine has a bug that causes such calls + * to fail (https://bugs.winehq.org/show_bug.cgi?id=50771). To work around + * this bug, we re-open the handle here */ + HANDLE write_attributes_handle; + + basic.FileAttributes = (info.FileAttributes & ~FILE_ATTRIBUTE_READONLY) | + FILE_ATTRIBUTE_ARCHIVE; + + write_attributes_handle = ReOpenFile(handle, FILE_WRITE_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | + FILE_SHARE_DELETE, + FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_BACKUP_SEMANTICS); + if (write_attributes_handle == INVALID_HANDLE_VALUE) { + SET_REQ_WIN32_ERROR(req, GetLastError()); + CloseHandle(handle); + return; + } + + status = pNtSetInformationFile(write_attributes_handle, + &iosb, + &basic, + sizeof basic, + FileBasicInformation); + CloseHandle(write_attributes_handle); + if (!NT_SUCCESS(status)) { + SET_REQ_WIN32_ERROR(req, pRtlNtStatusToDosError(status)); + CloseHandle(handle); + return; + } + } + + /* Try to set the delete flag. */ + disposition.DeleteFile = TRUE; + status = pNtSetInformationFile(handle, + &iosb, + &disposition, + sizeof disposition, + FileDispositionInformation); + if (NT_SUCCESS(status)) { + SET_REQ_SUCCESS(req); + } else { + SET_REQ_WIN32_ERROR(req, pRtlNtStatusToDosError(status)); + } + } else { + SET_REQ_WIN32_ERROR(req, error); + } + } + + CloseHandle(handle); +} + + +static void fs__rmdir(uv_fs_t* req) { + fs__unlink_rmdir(req, /*isrmdir*/1); +} + + +static void fs__unlink(uv_fs_t* req) { + fs__unlink_rmdir(req, /*isrmdir*/0); +} + + +void fs__mkdir(uv_fs_t* req) { + /* TODO: use req->mode. */ + if (CreateDirectoryW(req->file.pathw, NULL)) { + SET_REQ_RESULT(req, 0); + } else { + SET_REQ_WIN32_ERROR(req, GetLastError()); + if (req->sys_errno_ == ERROR_INVALID_NAME || + req->sys_errno_ == ERROR_DIRECTORY) + req->result = UV_EINVAL; + } +} + +typedef int (*uv__fs_mktemp_func)(uv_fs_t* req); + +/* OpenBSD original: lib/libc/stdio/mktemp.c */ +void fs__mktemp(uv_fs_t* req, uv__fs_mktemp_func func) { + static const WCHAR *tempchars = + L"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + static const size_t num_chars = 62; + static const size_t num_x = 6; + WCHAR *cp, *ep; + unsigned int tries, i; + size_t len; + uint64_t v; + char* path; + + path = (char*)req->path; + len = wcslen(req->file.pathw); + ep = req->file.pathw + len; + if (len < num_x || wcsncmp(ep - num_x, L"XXXXXX", num_x)) { + SET_REQ_UV_ERROR(req, UV_EINVAL, ERROR_INVALID_PARAMETER); + goto clobber; + } + + tries = TMP_MAX; + do { + if (uv__random_winrandom(&v, sizeof(v)) < 0) { + SET_REQ_UV_ERROR(req, UV_EIO, ERROR_IO_DEVICE); + goto clobber; + } + + cp = ep - num_x; + for (i = 0; i < num_x; i++) { + *cp++ = tempchars[v % num_chars]; + v /= num_chars; + } + + if (func(req)) { + if (req->result >= 0) { + len = strlen(path); + wcstombs(path + len - num_x, ep - num_x, num_x); + } + return; + } + } while (--tries); + + SET_REQ_WIN32_ERROR(req, GetLastError()); + +clobber: + path[0] = '\0'; +} + + +static int fs__mkdtemp_func(uv_fs_t* req) { + DWORD error; + if (CreateDirectoryW(req->file.pathw, NULL)) { + SET_REQ_RESULT(req, 0); + return 1; + } + error = GetLastError(); + if (error != ERROR_ALREADY_EXISTS) { + SET_REQ_WIN32_ERROR(req, error); + return 1; + } + + return 0; +} + + +void fs__mkdtemp(uv_fs_t* req) { + fs__mktemp(req, fs__mkdtemp_func); +} + + +static int fs__mkstemp_func(uv_fs_t* req) { + HANDLE file; + int fd; + + file = CreateFileW(req->file.pathw, + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + NULL, + CREATE_NEW, + FILE_ATTRIBUTE_NORMAL, + NULL); + + if (file == INVALID_HANDLE_VALUE) { + DWORD error; + error = GetLastError(); + + /* If the file exists, the main fs__mktemp() function + will retry. If it's another error, we want to stop. */ + if (error != ERROR_FILE_EXISTS) { + SET_REQ_WIN32_ERROR(req, error); + return 1; + } + + return 0; + } + + fd = _open_osfhandle((intptr_t) file, 0); + if (fd < 0) { + /* The only known failure mode for _open_osfhandle() is EMFILE, in which + * case GetLastError() will return zero. However we'll try to handle other + * errors as well, should they ever occur. + */ + if (errno == EMFILE) + SET_REQ_UV_ERROR(req, UV_EMFILE, ERROR_TOO_MANY_OPEN_FILES); + else if (GetLastError() != ERROR_SUCCESS) + SET_REQ_WIN32_ERROR(req, GetLastError()); + else + SET_REQ_WIN32_ERROR(req, UV_UNKNOWN); + CloseHandle(file); + return 1; + } + + SET_REQ_RESULT(req, fd); + + return 1; +} + + +void fs__mkstemp(uv_fs_t* req) { + fs__mktemp(req, fs__mkstemp_func); +} + + +void fs__scandir(uv_fs_t* req) { + static const size_t dirents_initial_size = 32; + + HANDLE dir_handle = INVALID_HANDLE_VALUE; + + uv__dirent_t** dirents = NULL; + size_t dirents_size = 0; + size_t dirents_used = 0; + + IO_STATUS_BLOCK iosb; + NTSTATUS status; + + /* Buffer to hold directory entries returned by NtQueryDirectoryFile. + * It's important that this buffer can hold at least one entry, regardless + * of the length of the file names present in the enumerated directory. + * A file name is at most 256 WCHARs long. + * According to MSDN, the buffer must be aligned at an 8-byte boundary. + */ +#if _MSC_VER + __declspec(align(8)) char buffer[8192]; +#else + __attribute__ ((aligned (8))) char buffer[8192]; +#endif + + STATIC_ASSERT(sizeof buffer >= + sizeof(FILE_DIRECTORY_INFORMATION) + 256 * sizeof(WCHAR)); + + /* Open the directory. */ + dir_handle = + CreateFileW(req->file.pathw, + FILE_LIST_DIRECTORY | SYNCHRONIZE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + NULL, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, + NULL); + if (dir_handle == INVALID_HANDLE_VALUE) + goto win32_error; + + /* Read the first chunk. */ + status = pNtQueryDirectoryFile(dir_handle, + NULL, + NULL, + NULL, + &iosb, + &buffer, + sizeof buffer, + FileDirectoryInformation, + FALSE, + NULL, + TRUE); + + /* If the handle is not a directory, we'll get STATUS_INVALID_PARAMETER. + * This should be reported back as UV_ENOTDIR. + */ + if (status == (NTSTATUS)STATUS_INVALID_PARAMETER) + goto not_a_directory_error; + + while (NT_SUCCESS(status)) { + char* position = buffer; + size_t next_entry_offset = 0; + + do { + FILE_DIRECTORY_INFORMATION* info; + uv__dirent_t* dirent; + + size_t wchar_len; + size_t wtf8_len; + char* wtf8; + + /* Obtain a pointer to the current directory entry. */ + position += next_entry_offset; + info = (FILE_DIRECTORY_INFORMATION*) position; + + /* Fetch the offset to the next directory entry. */ + next_entry_offset = info->NextEntryOffset; + + /* Compute the length of the filename in WCHARs. */ + wchar_len = info->FileNameLength / sizeof info->FileName[0]; + + /* Skip over '.' and '..' entries. It has been reported that + * the SharePoint driver includes the terminating zero byte in + * the filename length. Strip those first. + */ + while (wchar_len > 0 && info->FileName[wchar_len - 1] == L'\0') + wchar_len -= 1; + + if (wchar_len == 0) + continue; + if (wchar_len == 1 && info->FileName[0] == L'.') + continue; + if (wchar_len == 2 && info->FileName[0] == L'.' && + info->FileName[1] == L'.') + continue; + + /* Compute the space required to store the filename as WTF-8. */ + wtf8_len = uv_utf16_length_as_wtf8(&info->FileName[0], wchar_len); + + /* Resize the dirent array if needed. */ + if (dirents_used >= dirents_size) { + size_t new_dirents_size = + dirents_size == 0 ? dirents_initial_size : dirents_size << 1; + uv__dirent_t** new_dirents = + uv__realloc(dirents, new_dirents_size * sizeof *dirents); + + if (new_dirents == NULL) + goto out_of_memory_error; + + dirents_size = new_dirents_size; + dirents = new_dirents; + } + + /* Allocate space for the uv dirent structure. The dirent structure + * includes room for the first character of the filename, but `utf8_len` + * doesn't count the NULL terminator at this point. + */ + dirent = uv__malloc(sizeof *dirent + wtf8_len); + if (dirent == NULL) + goto out_of_memory_error; + + dirents[dirents_used++] = dirent; + + /* Convert file name to UTF-8. */ + wtf8 = &dirent->d_name[0]; + if (uv_utf16_to_wtf8(&info->FileName[0], wchar_len, &wtf8, &wtf8_len) != 0) + goto out_of_memory_error; + + /* Fill out the type field. */ + if (info->FileAttributes & FILE_ATTRIBUTE_DEVICE) + dirent->d_type = UV__DT_CHAR; + else if (info->FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) + dirent->d_type = UV__DT_LINK; + else if (info->FileAttributes & FILE_ATTRIBUTE_DIRECTORY) + dirent->d_type = UV__DT_DIR; + else + dirent->d_type = UV__DT_FILE; + } while (next_entry_offset != 0); + + /* Read the next chunk. */ + status = pNtQueryDirectoryFile(dir_handle, + NULL, + NULL, + NULL, + &iosb, + &buffer, + sizeof buffer, + FileDirectoryInformation, + FALSE, + NULL, + FALSE); + + /* After the first pNtQueryDirectoryFile call, the function may return + * STATUS_SUCCESS even if the buffer was too small to hold at least one + * directory entry. + */ + if (status == STATUS_SUCCESS && iosb.Information == 0) + status = STATUS_BUFFER_OVERFLOW; + } + + if (status != STATUS_NO_MORE_FILES) + goto nt_error; + + CloseHandle(dir_handle); + + /* Store the result in the request object. */ + req->ptr = dirents; + if (dirents != NULL) + req->flags |= UV_FS_FREE_PTR; + + SET_REQ_RESULT(req, dirents_used); + + /* `nbufs` will be used as index by uv_fs_scandir_next. */ + req->fs.info.nbufs = 0; + + return; + +nt_error: + SET_REQ_WIN32_ERROR(req, pRtlNtStatusToDosError(status)); + goto cleanup; + +win32_error: + SET_REQ_WIN32_ERROR(req, GetLastError()); + goto cleanup; + +not_a_directory_error: + SET_REQ_UV_ERROR(req, UV_ENOTDIR, ERROR_DIRECTORY); + goto cleanup; + +out_of_memory_error: + SET_REQ_UV_ERROR(req, UV_ENOMEM, ERROR_OUTOFMEMORY); + goto cleanup; + +cleanup: + if (dir_handle != INVALID_HANDLE_VALUE) + CloseHandle(dir_handle); + while (dirents_used > 0) + uv__free(dirents[--dirents_used]); + if (dirents != NULL) + uv__free(dirents); +} + +void fs__opendir(uv_fs_t* req) { + WCHAR* pathw; + size_t len; + const WCHAR* fmt; + WCHAR* find_path; + uv_dir_t* dir; + + pathw = req->file.pathw; + dir = NULL; + find_path = NULL; + + /* Figure out whether path is a file or a directory. */ + if (!(GetFileAttributesW(pathw) & FILE_ATTRIBUTE_DIRECTORY)) { + SET_REQ_UV_ERROR(req, UV_ENOTDIR, ERROR_DIRECTORY); + goto error; + } + + dir = uv__malloc(sizeof(*dir)); + if (dir == NULL) { + SET_REQ_UV_ERROR(req, UV_ENOMEM, ERROR_OUTOFMEMORY); + goto error; + } + + len = wcslen(pathw); + + if (len == 0) + fmt = L"./*"; + else if (IS_SLASH(pathw[len - 1])) + fmt = L"%s*"; + else + fmt = L"%s\\*"; + + find_path = uv__malloc(sizeof(WCHAR) * (len + 4)); + if (find_path == NULL) { + SET_REQ_UV_ERROR(req, UV_ENOMEM, ERROR_OUTOFMEMORY); + goto error; + } + + _snwprintf(find_path, len + 3, fmt, pathw); + dir->dir_handle = FindFirstFileW(find_path, &dir->find_data); + uv__free(find_path); + find_path = NULL; + if (dir->dir_handle == INVALID_HANDLE_VALUE && + GetLastError() != ERROR_FILE_NOT_FOUND) { + SET_REQ_WIN32_ERROR(req, GetLastError()); + goto error; + } + + dir->need_find_call = FALSE; + req->ptr = dir; + SET_REQ_RESULT(req, 0); + return; + +error: + uv__free(dir); + uv__free(find_path); + req->ptr = NULL; +} + +void fs__readdir(uv_fs_t* req) { + uv_dir_t* dir; + uv_dirent_t* dirents; + uv__dirent_t dent; + unsigned int dirent_idx; + PWIN32_FIND_DATAW find_data; + unsigned int i; + int r; + + req->flags |= UV_FS_FREE_PTR; + dir = req->ptr; + dirents = dir->dirents; + memset(dirents, 0, dir->nentries * sizeof(*dir->dirents)); + + /* Initial FindFirstFile can fail with ERROR_FILE_NOT_FOUND, meaning + * no matches, which leaves dir_handle set to INVALID_HANDLE_VALUE, + * see fs__opendir(). Not an actual error, just means no results. + * + * sshfs-win apparently works that way but reading an empty directory + * on a regular drive doesn't trigger that particular code path. + * + * See https://github.com/libuv/libuv/issues/4952 + */ + if (dir->dir_handle == INVALID_HANDLE_VALUE) { + SET_REQ_RESULT(req, 0); + return; + } + + find_data = &dir->find_data; + dirent_idx = 0; + + while (dirent_idx < dir->nentries) { + if (dir->need_find_call && FindNextFileW(dir->dir_handle, find_data) == 0) { + if (GetLastError() == ERROR_NO_MORE_FILES) + break; + goto error; + } + + /* Skip "." and ".." entries. */ + if (find_data->cFileName[0] == L'.' && + (find_data->cFileName[1] == L'\0' || + (find_data->cFileName[1] == L'.' && + find_data->cFileName[2] == L'\0'))) { + dir->need_find_call = TRUE; + continue; + } + + r = uv__convert_utf16_to_utf8((const WCHAR*) &find_data->cFileName, + -1, + (char**) &dirents[dirent_idx].name); + if (r != 0) + goto error; + + /* Copy file type. */ + if ((find_data->dwFileAttributes & FILE_ATTRIBUTE_DEVICE) != 0) + dent.d_type = UV__DT_CHAR; + else if ((find_data->dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) + dent.d_type = UV__DT_LINK; + else if ((find_data->dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0) + dent.d_type = UV__DT_DIR; + else + dent.d_type = UV__DT_FILE; + + dirents[dirent_idx].type = uv__fs_get_dirent_type(&dent); + dir->need_find_call = TRUE; + ++dirent_idx; + } + + SET_REQ_RESULT(req, dirent_idx); + return; + +error: + SET_REQ_WIN32_ERROR(req, GetLastError()); + for (i = 0; i < dirent_idx; ++i) { + uv__free((char*) dirents[i].name); + dirents[i].name = NULL; + } +} + +void fs__closedir(uv_fs_t* req) { + uv_dir_t* dir; + + dir = req->ptr; + FindClose(dir->dir_handle); + uv__free(req->ptr); + SET_REQ_RESULT(req, 0); +} + +static fs__stat_path_return_t fs__stat_path(WCHAR* path, + uv_stat_t* statbuf, + int do_lstat) { + FILE_STAT_BASIC_INFORMATION stat_info; + + /* Check if the new fast API is available. */ + if (!pGetFileInformationByName) { + return FS__STAT_PATH_TRY_SLOW; + } + + /* Check if the API call fails. */ + if (!pGetFileInformationByName(path, FileStatBasicByNameInfo, &stat_info, + sizeof(stat_info))) { + switch(GetLastError()) { + case ERROR_FILE_NOT_FOUND: + case ERROR_PATH_NOT_FOUND: + case ERROR_NOT_READY: + case ERROR_BAD_NET_NAME: + /* These errors aren't worth retrying with the slow path. */ + return FS__STAT_PATH_ERROR; + } + return FS__STAT_PATH_TRY_SLOW; + } + + /* A file handle is needed to get st_size for links. */ + if ((stat_info.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)) { + return FS__STAT_PATH_TRY_SLOW; + } + + if (stat_info.DeviceType == FILE_DEVICE_NULL) { + fs__stat_assign_statbuf_null(statbuf); + return FS__STAT_PATH_SUCCESS; + } + + fs__stat_assign_statbuf(statbuf, stat_info, do_lstat); + return FS__STAT_PATH_SUCCESS; +} + +static int fs__stat_handle(HANDLE handle, uv_stat_t* statbuf, int do_lstat) { + size_t target_length = 0; + FILE_FS_DEVICE_INFORMATION device_info; + FILE_ALL_INFORMATION file_info; + FILE_FS_VOLUME_INFORMATION volume_info; + NTSTATUS nt_status; + IO_STATUS_BLOCK io_status; + FILE_STAT_BASIC_INFORMATION stat_info; + + nt_status = pNtQueryVolumeInformationFile(handle, + &io_status, + &device_info, + sizeof device_info, + FileFsDeviceInformation); + + /* Buffer overflow (a warning status code) is expected here. */ + if (NT_ERROR(nt_status)) { + SetLastError(pRtlNtStatusToDosError(nt_status)); + return -1; + } + + /* If it's NUL device set fields as reasonable as possible and return. */ + if (device_info.DeviceType == FILE_DEVICE_NULL) { + fs__stat_assign_statbuf_null(statbuf); + return 0; + } + + nt_status = pNtQueryInformationFile(handle, + &io_status, + &file_info, + sizeof file_info, + FileAllInformation); + + /* Buffer overflow (a warning status code) is expected here. */ + if (NT_ERROR(nt_status)) { + SetLastError(pRtlNtStatusToDosError(nt_status)); + return -1; + } + + nt_status = pNtQueryVolumeInformationFile(handle, + &io_status, + &volume_info, + sizeof volume_info, + FileFsVolumeInformation); + + /* Buffer overflow (a warning status code) is expected here. */ + if (io_status.Status == STATUS_NOT_IMPLEMENTED) { + stat_info.VolumeSerialNumber.QuadPart = 0; + } else if (NT_ERROR(nt_status)) { + SetLastError(pRtlNtStatusToDosError(nt_status)); + return -1; + } else { + stat_info.VolumeSerialNumber.LowPart = volume_info.VolumeSerialNumber; + } + + stat_info.DeviceType = device_info.DeviceType; + stat_info.FileAttributes = file_info.BasicInformation.FileAttributes; + stat_info.NumberOfLinks = file_info.StandardInformation.NumberOfLinks; + stat_info.FileId.QuadPart = + file_info.InternalInformation.IndexNumber.QuadPart; + stat_info.ChangeTime.QuadPart = + file_info.BasicInformation.ChangeTime.QuadPart; + stat_info.CreationTime.QuadPart = + file_info.BasicInformation.CreationTime.QuadPart; + stat_info.LastAccessTime.QuadPart = + file_info.BasicInformation.LastAccessTime.QuadPart; + stat_info.LastWriteTime.QuadPart = + file_info.BasicInformation.LastWriteTime.QuadPart; + stat_info.AllocationSize.QuadPart = + file_info.StandardInformation.AllocationSize.QuadPart; + + if (do_lstat && + (file_info.BasicInformation.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)) { + /* + * If reading the link fails, the reparse point is not a symlink and needs + * to be treated as a regular file. The higher level lstat function will + * detect this failure and retry without do_lstat if appropriate. + */ + if (fs__readlink_handle(handle, NULL, &target_length) != 0) { + return -1; + } + stat_info.EndOfFile.QuadPart = target_length; + } else { + stat_info.EndOfFile.QuadPart = + file_info.StandardInformation.EndOfFile.QuadPart; + } + + fs__stat_assign_statbuf(statbuf, stat_info, do_lstat); + return 0; +} + +static void fs__stat_assign_statbuf_null(uv_stat_t* statbuf) { + memset(statbuf, 0, sizeof(uv_stat_t)); + statbuf->st_mode = _S_IFCHR; + statbuf->st_mode |= (_S_IREAD | _S_IWRITE) | ((_S_IREAD | _S_IWRITE) >> 3) | + ((_S_IREAD | _S_IWRITE) >> 6); + statbuf->st_nlink = 1; + statbuf->st_blksize = 4096; + statbuf->st_rdev = FILE_DEVICE_NULL << 16; +} + +static void fs__stat_assign_statbuf(uv_stat_t* statbuf, + FILE_STAT_BASIC_INFORMATION stat_info, + int do_lstat) { + statbuf->st_dev = stat_info.VolumeSerialNumber.LowPart; + + /* Todo: st_mode should probably always be 0666 for everyone. We might also + * want to report 0777 if the file is a .exe or a directory. + * + * Currently it's based on whether the 'readonly' attribute is set, which + * makes little sense because the semantics are so different: the 'read-only' + * flag is just a way for a user to protect against accidental deletion, and + * serves no security purpose. Windows uses ACLs for that. + * + * Also people now use uv_fs_chmod() to take away the writable bit for good + * reasons. Windows however just makes the file read-only, which makes it + * impossible to delete the file afterwards, since read-only files can't be + * deleted. + * + * IOW it's all just a clusterfuck and we should think of something that + * makes slightly more sense. + * + * And uv_fs_chmod should probably just fail on windows or be a total no-op. + * There's nothing sensible it can do anyway. + */ + statbuf->st_mode = 0; + + /* + * On Windows, FILE_ATTRIBUTE_REPARSE_POINT is a general purpose mechanism + * by which filesystem drivers can intercept and alter file system requests. + * + * The only reparse points we care about are symlinks and mount points, both + * of which are treated as POSIX symlinks. Further, we only care when + * invoked via lstat, which seeks information about the link instead of its + * target. Otherwise, reparse points must be treated as regular files. + */ + if (do_lstat && + (stat_info.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)) { + statbuf->st_mode |= S_IFLNK; + statbuf->st_size = stat_info.EndOfFile.QuadPart; + } + + if (statbuf->st_mode == 0) { + if (stat_info.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + statbuf->st_mode |= _S_IFDIR; + statbuf->st_size = 0; + } else { + statbuf->st_mode |= _S_IFREG; + statbuf->st_size = stat_info.EndOfFile.QuadPart; + } + } + + if (stat_info.FileAttributes & FILE_ATTRIBUTE_READONLY) + statbuf->st_mode |= _S_IREAD | (_S_IREAD >> 3) | (_S_IREAD >> 6); + else + statbuf->st_mode |= (_S_IREAD | _S_IWRITE) | ((_S_IREAD | _S_IWRITE) >> 3) | + ((_S_IREAD | _S_IWRITE) >> 6); + + uv__filetime_to_timespec(&statbuf->st_atim, + stat_info.LastAccessTime.QuadPart); + uv__filetime_to_timespec(&statbuf->st_ctim, + stat_info.ChangeTime.QuadPart); + uv__filetime_to_timespec(&statbuf->st_mtim, + stat_info.LastWriteTime.QuadPart); + uv__filetime_to_timespec(&statbuf->st_birthtim, + stat_info.CreationTime.QuadPart); + + statbuf->st_ino = stat_info.FileId.QuadPart; + + /* st_blocks contains the on-disk allocation size in 512-byte units. */ + statbuf->st_blocks = + (uint64_t) stat_info.AllocationSize.QuadPart >> 9; + + statbuf->st_nlink = stat_info.NumberOfLinks; + + /* The st_blksize is supposed to be the 'optimal' number of bytes for reading + * and writing to the disk. That is, for any definition of 'optimal' - it's + * supposed to at least avoid read-update-write behavior when writing to the + * disk. + * + * However nobody knows this and even fewer people actually use this value, + * and in order to fill it out we'd have to make another syscall to query the + * volume for FILE_FS_SECTOR_SIZE_INFORMATION. + * + * Therefore we'll just report a sensible value that's quite commonly okay + * on modern hardware. + * + * 4096 is the minimum required to be compatible with newer Advanced Format + * drives (which have 4096 bytes per physical sector), and to be backwards + * compatible with older drives (which have 512 bytes per physical sector). + */ + statbuf->st_blksize = 4096; + + /* Todo: set st_flags to something meaningful. Also provide a wrapper for + * chattr(2). + */ + statbuf->st_flags = 0; + + /* Windows has nothing sensible to say about these values, so they'll just + * remain empty. + */ + statbuf->st_gid = 0; + statbuf->st_uid = 0; + statbuf->st_rdev = 0; + statbuf->st_gen = 0; +} + + +static void fs__stat_prepare_path(WCHAR* pathw) { + size_t len = wcslen(pathw); + + if (len > 1 && pathw[len - 2] != L':' && + (pathw[len - 1] == L'\\' || pathw[len - 1] == L'/')) { + pathw[len - 1] = '\0'; + } +} + +static DWORD fs__stat_directory(WCHAR* path, + uv_stat_t* statbuf, + int do_lstat, + DWORD ret_error) { + HANDLE handle = INVALID_HANDLE_VALUE; + FILE_STAT_BASIC_INFORMATION stat_info; + FILE_ID_FULL_DIR_INFORMATION dir_info; + FILE_FS_VOLUME_INFORMATION volume_info; + FILE_FS_DEVICE_INFORMATION device_info; + IO_STATUS_BLOCK io_status; + NTSTATUS nt_status; + WCHAR* path_dirpath = NULL; + WCHAR* path_filename = NULL; + UNICODE_STRING FileMask; + size_t len; + size_t split; + WCHAR splitchar; + int includes_name; + + /* AKA strtok or wcscspn, in reverse. */ + len = wcslen(path); + split = len; + + includes_name = 0; + while (split > 0 && path[split - 1] != L'\\' && path[split - 1] != L'/' && + path[split - 1] != L':') { + /* check if the path contains a character other than /,\,:,. */ + if (path[split-1] != '.') { + includes_name = 1; + } + split--; + } + /* If the path is a relative path with a file name or a folder name */ + if (split == 0 && includes_name) { + path_dirpath = L"."; + /* If there is a slash or a backslash */ + } else if (path[split - 1] == L'\\' || path[split - 1] == L'/') { + path_dirpath = path; + /* If there is no filename, consider it as a relative folder path */ + if (!includes_name) { + split = len; + /* Else, split it */ + } else { + splitchar = path[split - 1]; + path[split - 1] = L'\0'; + } + /* e.g. "..", "c:" */ + } else { + path_dirpath = path; + split = len; + } + path_filename = &path[split]; + + len = 0; + while (1) { + if (path_filename[len] == L'\0') + break; + if (path_filename[len] == L'*' || path_filename[len] == L'?' || + path_filename[len] == L'>' || path_filename[len] == L'<' || + path_filename[len] == L'"') { + ret_error = ERROR_INVALID_NAME; + goto cleanup; + } + len++; + } + + /* Get directory handle */ + handle = CreateFileW(path_dirpath, + FILE_LIST_DIRECTORY, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + NULL, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, + NULL); + + if (handle == INVALID_HANDLE_VALUE) { + ret_error = GetLastError(); + goto cleanup; + } + + /* Get files in the directory */ + nt_status = uv__RtlUnicodeStringInit(&FileMask, path_filename, len); + if (!NT_SUCCESS(nt_status)) { + ret_error = pRtlNtStatusToDosError(nt_status); + goto cleanup; + } + nt_status = pNtQueryDirectoryFile(handle, + NULL, + NULL, + NULL, + &io_status, + &dir_info, + sizeof(dir_info), + FileIdFullDirectoryInformation, + TRUE, + &FileMask, + TRUE); + + /* Buffer overflow (a warning status code) is expected here since there isn't + * enough space to store the FileName, and actually indicates success. */ + if (!NT_SUCCESS(nt_status) && nt_status != STATUS_BUFFER_OVERFLOW) { + if (nt_status == STATUS_NO_MORE_FILES) + ret_error = ERROR_PATH_NOT_FOUND; + else + ret_error = pRtlNtStatusToDosError(nt_status); + goto cleanup; + } + + /* Assign values to stat_info */ + memset(&stat_info, 0, sizeof(FILE_STAT_BASIC_INFORMATION)); + stat_info.FileAttributes = dir_info.FileAttributes; + stat_info.CreationTime.QuadPart = dir_info.CreationTime.QuadPart; + stat_info.LastAccessTime.QuadPart = dir_info.LastAccessTime.QuadPart; + stat_info.LastWriteTime.QuadPart = dir_info.LastWriteTime.QuadPart; + if (stat_info.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) { + /* A file handle is needed to get st_size for the link (from + * FSCTL_GET_REPARSE_POINT), which is required by posix, but we are here + * because getting the file handle failed. We could get just the + * ReparsePointTag by querying FILE_ID_EXTD_DIR_INFORMATION instead to make + * sure this really is a link before giving up here on the uv_fs_stat call, + * but that doesn't seem essential. */ + if (!do_lstat) + goto cleanup; + stat_info.EndOfFile.QuadPart = 0; + stat_info.AllocationSize.QuadPart = 0; + } else { + stat_info.EndOfFile.QuadPart = dir_info.EndOfFile.QuadPart; + stat_info.AllocationSize.QuadPart = dir_info.AllocationSize.QuadPart; + } + stat_info.ChangeTime.QuadPart = dir_info.ChangeTime.QuadPart; + stat_info.FileId.QuadPart = dir_info.FileId.QuadPart; + + /* Finish up by getting device info from the directory handle, + * since files presumably must live on their device. */ + nt_status = pNtQueryVolumeInformationFile(handle, + &io_status, + &volume_info, + sizeof volume_info, + FileFsVolumeInformation); + + /* Buffer overflow (a warning status code) is expected here. */ + if (io_status.Status == STATUS_NOT_IMPLEMENTED) { + stat_info.VolumeSerialNumber.QuadPart = 0; + } else if (NT_ERROR(nt_status)) { + ret_error = pRtlNtStatusToDosError(nt_status); + goto cleanup; + } else { + stat_info.VolumeSerialNumber.QuadPart = volume_info.VolumeSerialNumber; + } + + nt_status = pNtQueryVolumeInformationFile(handle, + &io_status, + &device_info, + sizeof device_info, + FileFsDeviceInformation); + + /* Buffer overflow (a warning status code) is expected here. */ + if (NT_ERROR(nt_status)) { + ret_error = pRtlNtStatusToDosError(nt_status); + goto cleanup; + } + + stat_info.DeviceType = device_info.DeviceType; + stat_info.NumberOfLinks = 1; /* No way to recover this info. */ + + fs__stat_assign_statbuf(statbuf, stat_info, do_lstat); + ret_error = 0; + +cleanup: + if (split != 0) + path[split - 1] = splitchar; + if (handle != INVALID_HANDLE_VALUE) + CloseHandle(handle); + return ret_error; +} + +static DWORD fs__stat_impl_from_path(WCHAR* path, + int do_lstat, + uv_stat_t* statbuf) { + HANDLE handle; + DWORD flags; + DWORD ret; + + /* If new API exists, try to use it. */ + switch (fs__stat_path(path, statbuf, do_lstat)) { + case FS__STAT_PATH_SUCCESS: + return 0; + case FS__STAT_PATH_ERROR: + return GetLastError(); + case FS__STAT_PATH_TRY_SLOW: + break; + } + + /* If the new API does not exist, use the old API. */ + flags = FILE_FLAG_BACKUP_SEMANTICS; + if (do_lstat) + flags |= FILE_FLAG_OPEN_REPARSE_POINT; + + handle = CreateFileW(path, + FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + NULL, + OPEN_EXISTING, + flags, + NULL); + + if (handle == INVALID_HANDLE_VALUE) { + ret = GetLastError(); + if (ret != ERROR_ACCESS_DENIED && ret != ERROR_SHARING_VIOLATION) + return ret; + return fs__stat_directory(path, statbuf, do_lstat, ret); + } + + if (fs__stat_handle(handle, statbuf, do_lstat) != 0) + ret = GetLastError(); + else + ret = 0; + + CloseHandle(handle); + return ret; +} + + +static void fs__stat_impl(uv_fs_t* req, int do_lstat) { + DWORD error; + + error = fs__stat_impl_from_path(req->file.pathw, do_lstat, &req->statbuf); + if (error != 0) { + if (do_lstat && + (error == ERROR_SYMLINK_NOT_SUPPORTED || + error == ERROR_NOT_A_REPARSE_POINT)) { + /* We opened a reparse point but it was not a symlink. Try again. */ + fs__stat_impl(req, 0); + } else { + /* Stat failed. */ + SET_REQ_WIN32_ERROR(req, error); + } + + return; + } + + req->ptr = &req->statbuf; + SET_REQ_RESULT(req, 0); +} + + +static int fs__fstat_handle(int fd, HANDLE handle, uv_stat_t* statbuf) { + DWORD file_type; + + /* Each file type is processed differently. */ + file_type = uv_guess_handle(fd); + switch (file_type) { + /* Disk files use the existing logic from fs__stat_handle. */ + case UV_FILE: + return fs__stat_handle(handle, statbuf, 0); + + /* Devices and pipes are processed identically. There is no more information + * for them from any API. Fields are set as reasonably as possible and the + * function returns. */ + case UV_TTY: + case UV_NAMED_PIPE: + memset(statbuf, 0, sizeof(uv_stat_t)); + statbuf->st_mode = file_type == UV_TTY ? _S_IFCHR : _S_IFIFO; + statbuf->st_nlink = 1; + statbuf->st_rdev = (file_type == UV_TTY ? FILE_DEVICE_CONSOLE : FILE_DEVICE_NAMED_PIPE) << 16; + statbuf->st_ino = (uintptr_t) handle; + return 0; + + /* If file type is unknown it is an error. */ + case UV_UNKNOWN_HANDLE: + default: + SetLastError(ERROR_INVALID_HANDLE); + return -1; + } +} + + +static void fs__stat(uv_fs_t* req) { + fs__stat_prepare_path(req->file.pathw); + fs__stat_impl(req, 0); +} + + +static void fs__lstat(uv_fs_t* req) { + fs__stat_prepare_path(req->file.pathw); + fs__stat_impl(req, 1); +} + + +static void fs__fstat(uv_fs_t* req) { + int fd = req->file.fd; + HANDLE handle; + + VERIFY_FD(fd, req); + + handle = uv__get_osfhandle(fd); + + if (handle == INVALID_HANDLE_VALUE) { + SET_REQ_WIN32_ERROR(req, ERROR_INVALID_HANDLE); + return; + } + + if (fs__fstat_handle(fd, handle, &req->statbuf) != 0) { + SET_REQ_WIN32_ERROR(req, GetLastError()); + return; + } + + req->ptr = &req->statbuf; + SET_REQ_RESULT(req, 0); +} + + +static void fs__rename(uv_fs_t* req) { + if (!MoveFileExW(req->file.pathw, req->fs.info.new_pathw, MOVEFILE_REPLACE_EXISTING)) { + SET_REQ_WIN32_ERROR(req, GetLastError()); + return; + } + + SET_REQ_RESULT(req, 0); +} + + +static void fs__sync_impl(uv_fs_t* req) { + int fd = req->file.fd; + int result; + + VERIFY_FD(fd, req); + + result = FlushFileBuffers(uv__get_osfhandle(fd)) ? 0 : -1; + if (result == -1) { + SET_REQ_WIN32_ERROR(req, GetLastError()); + } else { + SET_REQ_RESULT(req, result); + } +} + + +static void fs__fsync(uv_fs_t* req) { + fs__sync_impl(req); +} + + +static void fs__fdatasync(uv_fs_t* req) { + fs__sync_impl(req); +} + + +static void fs__ftruncate(uv_fs_t* req) { + int fd = req->file.fd; + HANDLE handle; + struct uv__fd_info_s fd_info = { 0 }; + NTSTATUS status; + IO_STATUS_BLOCK io_status; + FILE_END_OF_FILE_INFORMATION eof_info; + + VERIFY_FD(fd, req); + + handle = uv__get_osfhandle(fd); + + if (uv__fd_hash_get(fd, &fd_info)) { + if (fd_info.is_directory) { + SET_REQ_WIN32_ERROR(req, ERROR_ACCESS_DENIED); + return; + } + + if (fd_info.mapping != INVALID_HANDLE_VALUE) { + CloseHandle(fd_info.mapping); + } + } + + eof_info.EndOfFile.QuadPart = req->fs.info.offset; + + status = pNtSetInformationFile(handle, + &io_status, + &eof_info, + sizeof eof_info, + FileEndOfFileInformation); + + if (NT_SUCCESS(status)) { + SET_REQ_RESULT(req, 0); + } else { + SET_REQ_WIN32_ERROR(req, pRtlNtStatusToDosError(status)); + + if (fd_info.flags) { + CloseHandle(handle); + fd_info.mapping = INVALID_HANDLE_VALUE; + fd_info.size.QuadPart = 0; + fd_info.current_pos.QuadPart = 0; + uv__fd_hash_add(fd, &fd_info); + return; + } + } + + if (fd_info.flags) { + fd_info.size = eof_info.EndOfFile; + + if (fd_info.size.QuadPart == 0) { + fd_info.mapping = INVALID_HANDLE_VALUE; + } else { + DWORD flProtect = (fd_info.flags & (UV_FS_O_RDONLY | UV_FS_O_WRONLY | + UV_FS_O_RDWR)) == UV_FS_O_RDONLY ? PAGE_READONLY : PAGE_READWRITE; + fd_info.mapping = CreateFileMapping(handle, + NULL, + flProtect, + fd_info.size.HighPart, + fd_info.size.LowPart, + NULL); + if (fd_info.mapping == NULL) { + SET_REQ_WIN32_ERROR(req, GetLastError()); + CloseHandle(handle); + fd_info.mapping = INVALID_HANDLE_VALUE; + fd_info.size.QuadPart = 0; + fd_info.current_pos.QuadPart = 0; + uv__fd_hash_add(fd, &fd_info); + return; + } + } + + uv__fd_hash_add(fd, &fd_info); + } +} + + +static void fs__copyfile(uv_fs_t* req) { + int flags; + int overwrite; + uv_stat_t statbuf; + uv_stat_t new_statbuf; + + flags = req->fs.info.file_flags; + + if (flags & UV_FS_COPYFILE_FICLONE_FORCE) { + SET_REQ_UV_ERROR(req, UV_ENOSYS, ERROR_NOT_SUPPORTED); + return; + } + + overwrite = flags & UV_FS_COPYFILE_EXCL; + + if (CopyFileW(req->file.pathw, req->fs.info.new_pathw, overwrite) != 0) { + SET_REQ_RESULT(req, 0); + return; + } + + SET_REQ_WIN32_ERROR(req, GetLastError()); + if (req->result != UV_EBUSY) + return; + + /* if error UV_EBUSY check if src and dst file are the same */ + if (fs__stat_impl_from_path(req->file.pathw, 0, &statbuf) != 0 || + fs__stat_impl_from_path(req->fs.info.new_pathw, 0, &new_statbuf) != 0) { + return; + } + + if (statbuf.st_dev == new_statbuf.st_dev && + statbuf.st_ino == new_statbuf.st_ino) { + SET_REQ_RESULT(req, 0); + } +} + + +static void fs__sendfile(uv_fs_t* req) { + int fd_in = req->file.fd, fd_out = req->fs.info.fd_out; + size_t length = req->fs.info.bufsml[0].len; + int64_t offset = req->fs.info.offset; + const size_t max_buf_size = 65536; + size_t buf_size = length < max_buf_size ? length : max_buf_size; + int n, result = 0; + int64_t result_offset = 0; + char* buf = (char*) uv__malloc(buf_size); + if (!buf) { + uv_fatal_error(ERROR_OUTOFMEMORY, "uv__malloc"); + } + + if (offset != -1) { + result_offset = _lseeki64(fd_in, offset, SEEK_SET); + } + + if (result_offset == -1) { + result = -1; + } else { + while (length > 0) { + n = _read(fd_in, buf, length < buf_size ? length : buf_size); + if (n == 0) { + break; + } else if (n == -1) { + result = -1; + break; + } + + length -= n; + + n = _write(fd_out, buf, n); + if (n == -1) { + result = -1; + break; + } + + result += n; + } + } + + uv__free(buf); + + SET_REQ_RESULT(req, result); +} + + +static void fs__access(uv_fs_t* req) { + DWORD attr = GetFileAttributesW(req->file.pathw); + + if (attr == INVALID_FILE_ATTRIBUTES) { + SET_REQ_WIN32_ERROR(req, GetLastError()); + return; + } + + /* + * Access is possible if + * - write access wasn't requested, + * - or the file isn't read-only, + * - or it's a directory. + * (Directories cannot be read-only on Windows.) + */ + if (!(req->fs.info.mode & W_OK) || + !(attr & FILE_ATTRIBUTE_READONLY) || + (attr & FILE_ATTRIBUTE_DIRECTORY)) { + SET_REQ_RESULT(req, 0); + } else { + SET_REQ_WIN32_ERROR(req, UV_EPERM); + } + +} + + +static void fs__chmod(uv_fs_t* req) { + int result = _wchmod(req->file.pathw, req->fs.info.mode); + if (result == -1) + SET_REQ_WIN32_ERROR(req, _doserrno); + else + SET_REQ_RESULT(req, 0); +} + + +static void fs__fchmod(uv_fs_t* req) { + int fd = req->file.fd; + int clear_archive_flag; + HANDLE handle; + NTSTATUS nt_status; + IO_STATUS_BLOCK io_status; + FILE_BASIC_INFORMATION file_info; + + VERIFY_FD(fd, req); + + handle = ReOpenFile(uv__get_osfhandle(fd), FILE_WRITE_ATTRIBUTES, 0, 0); + if (handle == INVALID_HANDLE_VALUE) { + SET_REQ_WIN32_ERROR(req, GetLastError()); + return; + } + + nt_status = pNtQueryInformationFile(handle, + &io_status, + &file_info, + sizeof file_info, + FileBasicInformation); + + if (!NT_SUCCESS(nt_status)) { + SET_REQ_WIN32_ERROR(req, pRtlNtStatusToDosError(nt_status)); + goto fchmod_cleanup; + } + + /* Test if the Archive attribute is cleared */ + if ((file_info.FileAttributes & FILE_ATTRIBUTE_ARCHIVE) == 0) { + /* Set Archive flag, otherwise setting or clearing the read-only + flag will not work */ + file_info.FileAttributes |= FILE_ATTRIBUTE_ARCHIVE; + nt_status = pNtSetInformationFile(handle, + &io_status, + &file_info, + sizeof file_info, + FileBasicInformation); + if (!NT_SUCCESS(nt_status)) { + SET_REQ_WIN32_ERROR(req, pRtlNtStatusToDosError(nt_status)); + goto fchmod_cleanup; + } + /* Remember to clear the flag later on */ + clear_archive_flag = 1; + } else { + clear_archive_flag = 0; + } + + if (req->fs.info.mode & _S_IWRITE) { + file_info.FileAttributes &= ~FILE_ATTRIBUTE_READONLY; + } else { + file_info.FileAttributes |= FILE_ATTRIBUTE_READONLY; + } + + nt_status = pNtSetInformationFile(handle, + &io_status, + &file_info, + sizeof file_info, + FileBasicInformation); + + if (!NT_SUCCESS(nt_status)) { + SET_REQ_WIN32_ERROR(req, pRtlNtStatusToDosError(nt_status)); + goto fchmod_cleanup; + } + + if (clear_archive_flag) { + file_info.FileAttributes &= ~FILE_ATTRIBUTE_ARCHIVE; + if (file_info.FileAttributes == 0) { + file_info.FileAttributes = FILE_ATTRIBUTE_NORMAL; + } + nt_status = pNtSetInformationFile(handle, + &io_status, + &file_info, + sizeof file_info, + FileBasicInformation); + if (!NT_SUCCESS(nt_status)) { + SET_REQ_WIN32_ERROR(req, pRtlNtStatusToDosError(nt_status)); + goto fchmod_cleanup; + } + } + + SET_REQ_SUCCESS(req); +fchmod_cleanup: + CloseHandle(handle); +} + + +static int fs__utime_handle(HANDLE handle, double atime, double mtime) { + FILETIME filetime_as, *filetime_a = &filetime_as; + FILETIME filetime_ms, *filetime_m = &filetime_ms; + FILETIME now; + + if (uv__isinf(atime) || uv__isinf(mtime)) + GetSystemTimeAsFileTime(&now); + + if (uv__isinf(atime)) + filetime_a = &now; + else if (uv__isnan(atime)) + filetime_a = NULL; + else + TIME_T_TO_FILETIME(atime, filetime_a); + + if (uv__isinf(mtime)) + filetime_m = &now; + else if (uv__isnan(mtime)) + filetime_m = NULL; + else + TIME_T_TO_FILETIME(mtime, filetime_m); + + if (!SetFileTime(handle, NULL, filetime_a, filetime_m)) + return -1; + + return 0; +} + +static DWORD fs__utime_impl_from_path(WCHAR* path, + double atime, + double mtime, + int do_lutime) { + HANDLE handle; + DWORD flags; + DWORD ret; + + flags = FILE_FLAG_BACKUP_SEMANTICS; + if (do_lutime) { + flags |= FILE_FLAG_OPEN_REPARSE_POINT; + } + + handle = CreateFileW(path, + FILE_WRITE_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + NULL, + OPEN_EXISTING, + flags, + NULL); + + if (handle == INVALID_HANDLE_VALUE) + return GetLastError(); + + if (fs__utime_handle(handle, atime, mtime) != 0) + ret = GetLastError(); + else + ret = 0; + + CloseHandle(handle); + return ret; +} + +static void fs__utime_impl(uv_fs_t* req, int do_lutime) { + DWORD error; + + error = fs__utime_impl_from_path(req->file.pathw, + req->fs.time.atime, + req->fs.time.mtime, + do_lutime); + + if (error != 0) { + if (do_lutime && + (error == ERROR_SYMLINK_NOT_SUPPORTED || + error == ERROR_NOT_A_REPARSE_POINT)) { + /* Opened file is a reparse point but not a symlink. Try again. */ + fs__utime_impl(req, 0); + } else { + /* utime failed. */ + SET_REQ_WIN32_ERROR(req, error); + } + + return; + } + + SET_REQ_RESULT(req, 0); +} + +static void fs__utime(uv_fs_t* req) { + fs__utime_impl(req, /* do_lutime */ 0); +} + + +static void fs__futime(uv_fs_t* req) { + int fd = req->file.fd; + HANDLE handle; + VERIFY_FD(fd, req); + + handle = uv__get_osfhandle(fd); + + if (handle == INVALID_HANDLE_VALUE) { + SET_REQ_WIN32_ERROR(req, ERROR_INVALID_HANDLE); + return; + } + + if (fs__utime_handle(handle, req->fs.time.atime, req->fs.time.mtime) != 0) { + SET_REQ_WIN32_ERROR(req, GetLastError()); + return; + } + + SET_REQ_RESULT(req, 0); +} + +static void fs__lutime(uv_fs_t* req) { + fs__utime_impl(req, /* do_lutime */ 1); +} + + +static void fs__link(uv_fs_t* req) { + DWORD r = CreateHardLinkW(req->fs.info.new_pathw, req->file.pathw, NULL); + if (r == 0) + SET_REQ_WIN32_ERROR(req, GetLastError()); + else + SET_REQ_RESULT(req, 0); +} + + +static void fs__create_junction(uv_fs_t* req, const WCHAR* path, + const WCHAR* new_path) { + HANDLE handle = INVALID_HANDLE_VALUE; + REPARSE_DATA_BUFFER *buffer = NULL; + int created = 0; + int target_len; + int is_absolute, is_long_path; + int needed_buf_size, used_buf_size, used_data_size, path_buf_len; + int start, len, i; + int add_slash; + DWORD bytes; + WCHAR* path_buf; + + target_len = wcslen(path); + is_long_path = wcsncmp(path, LONG_PATH_PREFIX, LONG_PATH_PREFIX_LEN) == 0; + + if (is_long_path) { + is_absolute = 1; + } else { + is_absolute = target_len >= 3 && IS_LETTER(path[0]) && + path[1] == L':' && IS_SLASH(path[2]); + } + + if (!is_absolute) { + /* Not supporting relative paths */ + SET_REQ_UV_ERROR(req, UV_EINVAL, ERROR_NOT_SUPPORTED); + return; + } + + /* Do a pessimistic calculation of the required buffer size */ + needed_buf_size = + FIELD_OFFSET(REPARSE_DATA_BUFFER, MountPointReparseBuffer.PathBuffer) + + JUNCTION_PREFIX_LEN * sizeof(WCHAR) + + 2 * (target_len + 2) * sizeof(WCHAR); + + /* Allocate the buffer */ + buffer = (REPARSE_DATA_BUFFER*)uv__malloc(needed_buf_size); + if (!buffer) { + uv_fatal_error(ERROR_OUTOFMEMORY, "uv__malloc"); + } + + /* Grab a pointer to the part of the buffer where filenames go */ + path_buf = (WCHAR*)&(buffer->MountPointReparseBuffer.PathBuffer); + path_buf_len = 0; + + /* Copy the substitute (internal) target path */ + start = path_buf_len; + + wcsncpy((WCHAR*)&path_buf[path_buf_len], JUNCTION_PREFIX, + JUNCTION_PREFIX_LEN); + path_buf_len += JUNCTION_PREFIX_LEN; + + add_slash = 0; + for (i = is_long_path ? LONG_PATH_PREFIX_LEN : 0; path[i] != L'\0'; i++) { + if (IS_SLASH(path[i])) { + add_slash = 1; + continue; + } + + if (add_slash) { + path_buf[path_buf_len++] = L'\\'; + add_slash = 0; + } + + path_buf[path_buf_len++] = path[i]; + } + if (add_slash) + path_buf[path_buf_len++] = L'\\'; + len = path_buf_len - start; + + /* Insert null terminator */ + path_buf[path_buf_len++] = L'\0'; + + /* Set the info about the substitute name */ + buffer->MountPointReparseBuffer.SubstituteNameOffset = start * sizeof(WCHAR); + buffer->MountPointReparseBuffer.SubstituteNameLength = len * sizeof(WCHAR); + + /* Copy the print name of the target path */ + start = path_buf_len; + add_slash = 0; + for (i = is_long_path ? LONG_PATH_PREFIX_LEN : 0; path[i] != L'\0'; i++) { + if (IS_SLASH(path[i])) { + add_slash = 1; + continue; + } + + if (add_slash) { + path_buf[path_buf_len++] = L'\\'; + add_slash = 0; + } + + path_buf[path_buf_len++] = path[i]; + } + len = path_buf_len - start; + if (len == 2 || add_slash) { + path_buf[path_buf_len++] = L'\\'; + len++; + } + + /* Insert another null terminator */ + path_buf[path_buf_len++] = L'\0'; + + /* Set the info about the print name */ + buffer->MountPointReparseBuffer.PrintNameOffset = start * sizeof(WCHAR); + buffer->MountPointReparseBuffer.PrintNameLength = len * sizeof(WCHAR); + + /* Calculate how much buffer space was actually used */ + used_buf_size = FIELD_OFFSET(REPARSE_DATA_BUFFER, MountPointReparseBuffer.PathBuffer) + + path_buf_len * sizeof(WCHAR); + used_data_size = used_buf_size - + FIELD_OFFSET(REPARSE_DATA_BUFFER, MountPointReparseBuffer); + + /* Put general info in the data buffer */ + buffer->ReparseTag = IO_REPARSE_TAG_MOUNT_POINT; + buffer->ReparseDataLength = used_data_size; + buffer->Reserved = 0; + + /* Create a new directory */ + if (!CreateDirectoryW(new_path, NULL)) { + SET_REQ_WIN32_ERROR(req, GetLastError()); + goto error; + } + created = 1; + + /* Open the directory */ + handle = CreateFileW(new_path, + GENERIC_WRITE, + 0, + NULL, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | + FILE_FLAG_OPEN_REPARSE_POINT, + NULL); + if (handle == INVALID_HANDLE_VALUE) { + SET_REQ_WIN32_ERROR(req, GetLastError()); + goto error; + } + + /* Create the actual reparse point */ + if (!DeviceIoControl(handle, + FSCTL_SET_REPARSE_POINT, + buffer, + used_buf_size, + NULL, + 0, + &bytes, + NULL)) { + SET_REQ_WIN32_ERROR(req, GetLastError()); + goto error; + } + + /* Clean up */ + CloseHandle(handle); + uv__free(buffer); + + SET_REQ_RESULT(req, 0); + return; + +error: + uv__free(buffer); + + if (handle != INVALID_HANDLE_VALUE) { + CloseHandle(handle); + } + + if (created) { + RemoveDirectoryW(new_path); + } +} + + +static void fs__symlink(uv_fs_t* req) { + WCHAR* pathw; + WCHAR* new_pathw; + int flags; + int err; + + pathw = req->file.pathw; + new_pathw = req->fs.info.new_pathw; + + if (req->fs.info.file_flags & UV_FS_SYMLINK_JUNCTION) { + fs__create_junction(req, pathw, new_pathw); + return; + } + + if (req->fs.info.file_flags & UV_FS_SYMLINK_DIR) + flags = SYMBOLIC_LINK_FLAG_DIRECTORY | uv__file_symlink_usermode_flag; + else + flags = uv__file_symlink_usermode_flag; + + if (CreateSymbolicLinkW(new_pathw, pathw, flags)) { + SET_REQ_RESULT(req, 0); + return; + } + + /* Something went wrong. We will test if it is because of user-mode + * symlinks. + */ + err = GetLastError(); + if (err == ERROR_INVALID_PARAMETER && + flags & SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE) { + /* This system does not support user-mode symlinks. We will clear the + * unsupported flag and retry. + */ + uv__file_symlink_usermode_flag = 0; + fs__symlink(req); + } else { + SET_REQ_WIN32_ERROR(req, err); + } +} + + +static void fs__readlink(uv_fs_t* req) { + HANDLE handle; + + handle = CreateFileW(req->file.pathw, + 0, + 0, + NULL, + OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, + NULL); + + if (handle == INVALID_HANDLE_VALUE) { + SET_REQ_WIN32_ERROR(req, GetLastError()); + return; + } + + assert(req->ptr == NULL); + if (fs__readlink_handle(handle, (char**) &req->ptr, NULL) != 0) { + DWORD error = GetLastError(); + SET_REQ_WIN32_ERROR(req, error); + if (error == ERROR_NOT_A_REPARSE_POINT) + req->result = UV_EINVAL; + CloseHandle(handle); + return; + } + + req->flags |= UV_FS_FREE_PTR; + SET_REQ_RESULT(req, 0); + + CloseHandle(handle); +} + + +static ssize_t fs__realpath_handle(HANDLE handle, char** realpath_ptr) { + int r; + DWORD w_realpath_len; + WCHAR* w_realpath_ptr = NULL; + WCHAR* w_realpath_buf; + + w_realpath_len = GetFinalPathNameByHandleW(handle, NULL, 0, VOLUME_NAME_DOS); + if (w_realpath_len == 0) { + return -1; + } + + w_realpath_buf = uv__malloc((w_realpath_len + 1) * sizeof(WCHAR)); + if (w_realpath_buf == NULL) { + SetLastError(ERROR_OUTOFMEMORY); + return -1; + } + w_realpath_ptr = w_realpath_buf; + + if (GetFinalPathNameByHandleW( + handle, w_realpath_ptr, w_realpath_len, VOLUME_NAME_DOS) == 0) { + uv__free(w_realpath_buf); + SetLastError(ERROR_INVALID_HANDLE); + return -1; + } + + /* convert UNC path to long path */ + if (wcsncmp(w_realpath_ptr, + UNC_PATH_PREFIX, + UNC_PATH_PREFIX_LEN) == 0) { + w_realpath_ptr += 6; + *w_realpath_ptr = L'\\'; + w_realpath_len -= 6; + } else if (wcsncmp(w_realpath_ptr, + LONG_PATH_PREFIX, + LONG_PATH_PREFIX_LEN) == 0) { + w_realpath_ptr += 4; + w_realpath_len -= 4; + } else { + uv__free(w_realpath_buf); + SetLastError(ERROR_INVALID_HANDLE); + return -1; + } + + assert(*realpath_ptr == NULL); + r = uv_utf16_to_wtf8(w_realpath_ptr, w_realpath_len, realpath_ptr, NULL); + uv__free(w_realpath_buf); + return r; +} + +static void fs__realpath(uv_fs_t* req) { + HANDLE handle; + + handle = CreateFileW(req->file.pathw, + 0, + 0, + NULL, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_BACKUP_SEMANTICS, + NULL); + if (handle == INVALID_HANDLE_VALUE) { + SET_REQ_WIN32_ERROR(req, GetLastError()); + return; + } + + assert(req->ptr == NULL); + if (fs__realpath_handle(handle, (char**) &req->ptr) == -1) { + CloseHandle(handle); + SET_REQ_WIN32_ERROR(req, GetLastError()); + return; + } + + CloseHandle(handle); + req->flags |= UV_FS_FREE_PTR; + SET_REQ_RESULT(req, 0); +} + + +static void fs__chown(uv_fs_t* req) { + SET_REQ_RESULT(req, 0); +} + + +static void fs__fchown(uv_fs_t* req) { + SET_REQ_RESULT(req, 0); +} + + +static void fs__lchown(uv_fs_t* req) { + SET_REQ_RESULT(req, 0); +} + + +static void fs__statfs(uv_fs_t* req) { + FILE_FS_FULL_SIZE_INFORMATION info; + IO_STATUS_BLOCK io_status; + uv_statfs_t* stat_fs; + NTSTATUS nt_status; + HANDLE handle; + + handle = CreateFileW(req->file.pathw, + FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + NULL, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, + NULL); + + if (handle == INVALID_HANDLE_VALUE) { + SET_REQ_WIN32_ERROR(req, GetLastError()); + return; + } + + nt_status = pNtQueryVolumeInformationFile(handle, + &io_status, + &info, + sizeof info, + FileFsFullSizeInformation); + CloseHandle(handle); + + if (NT_ERROR(nt_status)) { + SET_REQ_WIN32_ERROR(req, pRtlNtStatusToDosError(nt_status)); + return; + } + + stat_fs = uv__malloc(sizeof(*stat_fs)); + if (stat_fs == NULL) { + SET_REQ_UV_ERROR(req, UV_ENOMEM, ERROR_OUTOFMEMORY); + return; + } + + stat_fs->f_type = 0; + stat_fs->f_bsize = (uint64_t)info.SectorsPerAllocationUnit * + info.BytesPerSector; + stat_fs->f_frsize = stat_fs->f_bsize; + stat_fs->f_blocks = info.TotalAllocationUnits.QuadPart; + stat_fs->f_bfree = info.ActualAvailableAllocationUnits.QuadPart; + stat_fs->f_bavail = info.CallerAvailableAllocationUnits.QuadPart; + stat_fs->f_files = 0; + stat_fs->f_ffree = 0; + req->ptr = stat_fs; + req->flags |= UV_FS_FREE_PTR; + SET_REQ_RESULT(req, 0); +} + + +static void uv__fs_work(struct uv__work* w) { + uv_fs_t* req; + + req = container_of(w, uv_fs_t, work_req); + assert(req->type == UV_FS); + +#define XX(uc, lc) case UV_FS_##uc: fs__##lc(req); break; + switch (req->fs_type) { + XX(OPEN, open) + XX(CLOSE, close) + XX(READ, read) + XX(WRITE, write) + XX(COPYFILE, copyfile) + XX(SENDFILE, sendfile) + XX(STAT, stat) + XX(LSTAT, lstat) + XX(FSTAT, fstat) + XX(FTRUNCATE, ftruncate) + XX(UTIME, utime) + XX(FUTIME, futime) + XX(LUTIME, lutime) + XX(ACCESS, access) + XX(CHMOD, chmod) + XX(FCHMOD, fchmod) + XX(FSYNC, fsync) + XX(FDATASYNC, fdatasync) + XX(UNLINK, unlink) + XX(RMDIR, rmdir) + XX(MKDIR, mkdir) + XX(MKDTEMP, mkdtemp) + XX(MKSTEMP, mkstemp) + XX(RENAME, rename) + XX(SCANDIR, scandir) + XX(READDIR, readdir) + XX(OPENDIR, opendir) + XX(CLOSEDIR, closedir) + XX(LINK, link) + XX(SYMLINK, symlink) + XX(READLINK, readlink) + XX(REALPATH, realpath) + XX(CHOWN, chown) + XX(FCHOWN, fchown) + XX(LCHOWN, lchown) + XX(STATFS, statfs) + default: + assert(!"bad uv_fs_type"); + } +} + + +static void uv__fs_done(struct uv__work* w, int status) { + uv_fs_t* req; + + req = container_of(w, uv_fs_t, work_req); + uv__req_unregister(req->loop); + + if (status == UV_ECANCELED) { + assert(req->result == 0); + SET_REQ_UV_ERROR(req, UV_ECANCELED, 0); + } + + req->cb(req); +} + + +void uv_fs_req_cleanup(uv_fs_t* req) { + if (req == NULL) + return; + + if (req->flags & UV_FS_CLEANEDUP) + return; + + if (req->flags & UV_FS_FREE_PATHS) + uv__free(req->file.pathw); + + if (req->flags & UV_FS_FREE_PTR) { + if (req->fs_type == UV_FS_SCANDIR && req->ptr != NULL) + uv__fs_scandir_cleanup(req); + else if (req->fs_type == UV_FS_READDIR) + uv__fs_readdir_cleanup(req); + else + uv__free(req->ptr); + } + + if (req->fs.info.bufs != req->fs.info.bufsml) + uv__free(req->fs.info.bufs); + + req->path = NULL; + req->file.pathw = NULL; + req->fs.info.new_pathw = NULL; + req->fs.info.bufs = NULL; + req->ptr = NULL; + + req->flags |= UV_FS_CLEANEDUP; +} + + +int uv_fs_open(uv_loop_t* loop, uv_fs_t* req, const char* path, int flags, + int mode, uv_fs_cb cb) { + int err; + + INIT(UV_FS_OPEN); + err = fs__capture_path(req, path, NULL, cb != NULL); + if (err) { + SET_REQ_WIN32_ERROR(req, err); + return req->result; + } + + req->fs.info.file_flags = flags; + req->fs.info.mode = mode; + POST; +} + + +int uv_fs_close(uv_loop_t* loop, uv_fs_t* req, uv_file fd, uv_fs_cb cb) { + INIT(UV_FS_CLOSE); + req->file.fd = fd; + POST; +} + + +int uv_fs_read(uv_loop_t* loop, + uv_fs_t* req, + uv_file fd, + const uv_buf_t bufs[], + unsigned int nbufs, + int64_t offset, + uv_fs_cb cb) { + INIT(UV_FS_READ); + + if (bufs == NULL || nbufs == 0) { + SET_REQ_UV_ERROR(req, UV_EINVAL, ERROR_INVALID_PARAMETER); + return UV_EINVAL; + } + + req->file.fd = fd; + + req->fs.info.nbufs = nbufs; + req->fs.info.bufs = req->fs.info.bufsml; + if (nbufs > ARRAY_SIZE(req->fs.info.bufsml)) + req->fs.info.bufs = uv__malloc(nbufs * sizeof(*bufs)); + + if (req->fs.info.bufs == NULL) { + SET_REQ_UV_ERROR(req, UV_ENOMEM, ERROR_OUTOFMEMORY); + return UV_ENOMEM; + } + + memcpy(req->fs.info.bufs, bufs, nbufs * sizeof(*bufs)); + + req->fs.info.offset = offset; + POST; +} + + +int uv_fs_write(uv_loop_t* loop, + uv_fs_t* req, + uv_file fd, + const uv_buf_t bufs[], + unsigned int nbufs, + int64_t offset, + uv_fs_cb cb) { + INIT(UV_FS_WRITE); + + if (bufs == NULL || nbufs == 0) { + SET_REQ_UV_ERROR(req, UV_EINVAL, ERROR_INVALID_PARAMETER); + return UV_EINVAL; + } + + req->file.fd = fd; + + req->fs.info.nbufs = nbufs; + req->fs.info.bufs = req->fs.info.bufsml; + if (nbufs > ARRAY_SIZE(req->fs.info.bufsml)) + req->fs.info.bufs = uv__malloc(nbufs * sizeof(*bufs)); + + if (req->fs.info.bufs == NULL) { + SET_REQ_UV_ERROR(req, UV_ENOMEM, ERROR_OUTOFMEMORY); + return UV_ENOMEM; + } + + memcpy(req->fs.info.bufs, bufs, nbufs * sizeof(*bufs)); + + req->fs.info.offset = offset; + POST; +} + + +int uv_fs_unlink(uv_loop_t* loop, uv_fs_t* req, const char* path, + uv_fs_cb cb) { + int err; + + INIT(UV_FS_UNLINK); + err = fs__capture_path(req, path, NULL, cb != NULL); + if (err) { + SET_REQ_WIN32_ERROR(req, err); + return req->result; + } + + POST; +} + + +int uv_fs_mkdir(uv_loop_t* loop, uv_fs_t* req, const char* path, int mode, + uv_fs_cb cb) { + int err; + + INIT(UV_FS_MKDIR); + err = fs__capture_path(req, path, NULL, cb != NULL); + if (err) { + SET_REQ_WIN32_ERROR(req, err); + return req->result; + } + + req->fs.info.mode = mode; + POST; +} + + +int uv_fs_mkdtemp(uv_loop_t* loop, + uv_fs_t* req, + const char* tpl, + uv_fs_cb cb) { + int err; + + INIT(UV_FS_MKDTEMP); + err = fs__capture_path(req, tpl, NULL, TRUE); + if (err) { + SET_REQ_WIN32_ERROR(req, err); + return req->result; + } + + POST; +} + + +int uv_fs_mkstemp(uv_loop_t* loop, + uv_fs_t* req, + const char* tpl, + uv_fs_cb cb) { + int err; + + INIT(UV_FS_MKSTEMP); + err = fs__capture_path(req, tpl, NULL, TRUE); + if (err) { + SET_REQ_WIN32_ERROR(req, err); + return req->result; + } + + POST; +} + + +int uv_fs_rmdir(uv_loop_t* loop, uv_fs_t* req, const char* path, uv_fs_cb cb) { + int err; + + INIT(UV_FS_RMDIR); + err = fs__capture_path(req, path, NULL, cb != NULL); + if (err) { + SET_REQ_WIN32_ERROR(req, err); + return req->result; + } + + POST; +} + + +int uv_fs_scandir(uv_loop_t* loop, uv_fs_t* req, const char* path, int flags, + uv_fs_cb cb) { + int err; + + INIT(UV_FS_SCANDIR); + err = fs__capture_path(req, path, NULL, cb != NULL); + if (err) { + SET_REQ_WIN32_ERROR(req, err); + return req->result; + } + + req->fs.info.file_flags = flags; + POST; +} + +int uv_fs_opendir(uv_loop_t* loop, + uv_fs_t* req, + const char* path, + uv_fs_cb cb) { + int err; + + INIT(UV_FS_OPENDIR); + err = fs__capture_path(req, path, NULL, cb != NULL); + if (err) { + SET_REQ_WIN32_ERROR(req, err); + return req->result; + } + POST; +} + +int uv_fs_readdir(uv_loop_t* loop, + uv_fs_t* req, + uv_dir_t* dir, + uv_fs_cb cb) { + INIT(UV_FS_READDIR); + + if (dir == NULL || + dir->dirents == NULL || + dir->dir_handle == INVALID_HANDLE_VALUE) { + SET_REQ_UV_ERROR(req, UV_EINVAL, ERROR_INVALID_PARAMETER); + return UV_EINVAL; + } + + req->ptr = dir; + POST; +} + +int uv_fs_closedir(uv_loop_t* loop, + uv_fs_t* req, + uv_dir_t* dir, + uv_fs_cb cb) { + INIT(UV_FS_CLOSEDIR); + if (dir == NULL) { + SET_REQ_UV_ERROR(req, UV_EINVAL, ERROR_INVALID_PARAMETER); + return UV_EINVAL; + } + req->ptr = dir; + POST; +} + +int uv_fs_link(uv_loop_t* loop, uv_fs_t* req, const char* path, + const char* new_path, uv_fs_cb cb) { + int err; + + INIT(UV_FS_LINK); + err = fs__capture_path(req, path, new_path, cb != NULL); + if (err) { + SET_REQ_WIN32_ERROR(req, err); + return req->result; + } + + POST; +} + + +int uv_fs_symlink(uv_loop_t* loop, uv_fs_t* req, const char* path, + const char* new_path, int flags, uv_fs_cb cb) { + int err; + + INIT(UV_FS_SYMLINK); + err = fs__capture_path(req, path, new_path, cb != NULL); + if (err) { + SET_REQ_WIN32_ERROR(req, err); + return req->result; + } + + req->fs.info.file_flags = flags; + POST; +} + + +int uv_fs_readlink(uv_loop_t* loop, uv_fs_t* req, const char* path, + uv_fs_cb cb) { + int err; + + INIT(UV_FS_READLINK); + err = fs__capture_path(req, path, NULL, cb != NULL); + if (err) { + SET_REQ_WIN32_ERROR(req, err); + return req->result; + } + + POST; +} + + +int uv_fs_realpath(uv_loop_t* loop, uv_fs_t* req, const char* path, + uv_fs_cb cb) { + int err; + + INIT(UV_FS_REALPATH); + + if (!path) { + SET_REQ_UV_ERROR(req, UV_EINVAL, ERROR_INVALID_PARAMETER); + return UV_EINVAL; + } + + err = fs__capture_path(req, path, NULL, cb != NULL); + if (err) { + SET_REQ_WIN32_ERROR(req, err); + return req->result; + } + + POST; +} + + +int uv_fs_chown(uv_loop_t* loop, uv_fs_t* req, const char* path, uv_uid_t uid, + uv_gid_t gid, uv_fs_cb cb) { + int err; + + INIT(UV_FS_CHOWN); + err = fs__capture_path(req, path, NULL, cb != NULL); + if (err) { + SET_REQ_WIN32_ERROR(req, err); + return req->result; + } + + POST; +} + + +int uv_fs_fchown(uv_loop_t* loop, uv_fs_t* req, uv_file fd, uv_uid_t uid, + uv_gid_t gid, uv_fs_cb cb) { + INIT(UV_FS_FCHOWN); + POST; +} + + +int uv_fs_lchown(uv_loop_t* loop, uv_fs_t* req, const char* path, uv_uid_t uid, + uv_gid_t gid, uv_fs_cb cb) { + int err; + + INIT(UV_FS_LCHOWN); + err = fs__capture_path(req, path, NULL, cb != NULL); + if (err) { + SET_REQ_WIN32_ERROR(req, err); + return req->result; + } + + POST; +} + + +int uv_fs_stat(uv_loop_t* loop, uv_fs_t* req, const char* path, uv_fs_cb cb) { + int err; + + INIT(UV_FS_STAT); + err = fs__capture_path(req, path, NULL, cb != NULL); + if (err) { + SET_REQ_WIN32_ERROR(req, err); + return req->result; + } + + POST; +} + + +int uv_fs_lstat(uv_loop_t* loop, uv_fs_t* req, const char* path, uv_fs_cb cb) { + int err; + + INIT(UV_FS_LSTAT); + err = fs__capture_path(req, path, NULL, cb != NULL); + if (err) { + SET_REQ_WIN32_ERROR(req, err); + return req->result; + } + + POST; +} + + +int uv_fs_fstat(uv_loop_t* loop, uv_fs_t* req, uv_file fd, uv_fs_cb cb) { + INIT(UV_FS_FSTAT); + req->file.fd = fd; + POST; +} + + +int uv_fs_rename(uv_loop_t* loop, uv_fs_t* req, const char* path, + const char* new_path, uv_fs_cb cb) { + int err; + + INIT(UV_FS_RENAME); + err = fs__capture_path(req, path, new_path, cb != NULL); + if (err) { + SET_REQ_WIN32_ERROR(req, err); + return req->result; + } + + POST; +} + + +int uv_fs_fsync(uv_loop_t* loop, uv_fs_t* req, uv_file fd, uv_fs_cb cb) { + INIT(UV_FS_FSYNC); + req->file.fd = fd; + POST; +} + + +int uv_fs_fdatasync(uv_loop_t* loop, uv_fs_t* req, uv_file fd, uv_fs_cb cb) { + INIT(UV_FS_FDATASYNC); + req->file.fd = fd; + POST; +} + + +int uv_fs_ftruncate(uv_loop_t* loop, uv_fs_t* req, uv_file fd, + int64_t offset, uv_fs_cb cb) { + INIT(UV_FS_FTRUNCATE); + req->file.fd = fd; + req->fs.info.offset = offset; + POST; +} + + +int uv_fs_copyfile(uv_loop_t* loop, + uv_fs_t* req, + const char* path, + const char* new_path, + int flags, + uv_fs_cb cb) { + int err; + + INIT(UV_FS_COPYFILE); + + if (flags & ~(UV_FS_COPYFILE_EXCL | + UV_FS_COPYFILE_FICLONE | + UV_FS_COPYFILE_FICLONE_FORCE)) { + SET_REQ_UV_ERROR(req, UV_EINVAL, ERROR_INVALID_PARAMETER); + return UV_EINVAL; + } + + err = fs__capture_path(req, path, new_path, cb != NULL); + if (err) { + SET_REQ_WIN32_ERROR(req, err); + return req->result; + } + + req->fs.info.file_flags = flags; + POST; +} + + +int uv_fs_sendfile(uv_loop_t* loop, uv_fs_t* req, uv_file fd_out, + uv_file fd_in, int64_t in_offset, size_t length, uv_fs_cb cb) { + INIT(UV_FS_SENDFILE); + req->file.fd = fd_in; + req->fs.info.fd_out = fd_out; + req->fs.info.offset = in_offset; + req->fs.info.bufsml[0].len = length; + POST; +} + + +int uv_fs_access(uv_loop_t* loop, + uv_fs_t* req, + const char* path, + int flags, + uv_fs_cb cb) { + int err; + + INIT(UV_FS_ACCESS); + err = fs__capture_path(req, path, NULL, cb != NULL); + if (err) { + SET_REQ_WIN32_ERROR(req, err); + return req->result; + } + + req->fs.info.mode = flags; + POST; +} + + +int uv_fs_chmod(uv_loop_t* loop, uv_fs_t* req, const char* path, int mode, + uv_fs_cb cb) { + int err; + + INIT(UV_FS_CHMOD); + err = fs__capture_path(req, path, NULL, cb != NULL); + if (err) { + SET_REQ_WIN32_ERROR(req, err); + return req->result; + } + + req->fs.info.mode = mode; + POST; +} + + +int uv_fs_fchmod(uv_loop_t* loop, uv_fs_t* req, uv_file fd, int mode, + uv_fs_cb cb) { + INIT(UV_FS_FCHMOD); + req->file.fd = fd; + req->fs.info.mode = mode; + POST; +} + + +int uv_fs_utime(uv_loop_t* loop, uv_fs_t* req, const char* path, double atime, + double mtime, uv_fs_cb cb) { + int err; + + INIT(UV_FS_UTIME); + err = fs__capture_path(req, path, NULL, cb != NULL); + if (err) { + SET_REQ_WIN32_ERROR(req, err); + return req->result; + } + + req->fs.time.atime = atime; + req->fs.time.mtime = mtime; + POST; +} + + +int uv_fs_futime(uv_loop_t* loop, uv_fs_t* req, uv_file fd, double atime, + double mtime, uv_fs_cb cb) { + INIT(UV_FS_FUTIME); + req->file.fd = fd; + req->fs.time.atime = atime; + req->fs.time.mtime = mtime; + POST; +} + +int uv_fs_lutime(uv_loop_t* loop, uv_fs_t* req, const char* path, double atime, + double mtime, uv_fs_cb cb) { + int err; + + INIT(UV_FS_LUTIME); + err = fs__capture_path(req, path, NULL, cb != NULL); + if (err) { + SET_REQ_WIN32_ERROR(req, err); + return req->result; + } + + req->fs.time.atime = atime; + req->fs.time.mtime = mtime; + POST; +} + + +int uv_fs_statfs(uv_loop_t* loop, + uv_fs_t* req, + const char* path, + uv_fs_cb cb) { + int err; + + INIT(UV_FS_STATFS); + err = fs__capture_path(req, path, NULL, cb != NULL); + if (err) { + SET_REQ_WIN32_ERROR(req, err); + return req->result; + } + + POST; +} + +int uv_fs_get_system_error(const uv_fs_t* req) { + return req->sys_errno_; +} diff --git a/rag/parent-delivery/sources/node-v24.20.0-libuv-win-fs.metadata.json b/rag/parent-delivery/sources/node-v24.20.0-libuv-win-fs.metadata.json new file mode 100644 index 00000000..7d16d9fe --- /dev/null +++ b/rag/parent-delivery/sources/node-v24.20.0-libuv-win-fs.metadata.json @@ -0,0 +1,17 @@ +{ + "source": "https://raw.githubusercontent.com/nodejs/node/v24.20.0/deps/uv/src/win/fs.c", + "fetched_at": "2026-09-29", + "kind": "一次実装原文", + "sha256": "41633b8f58bb08c21f2dc8daa32990365e2df7154bb167a076e470339a218d18", + "license": "原文にあるMIT許諾を保持", + "file": "node-v24.20.0-libuv-win-fs.c.txt", + "verified_facts": [ + "Node24.20.0のrenameはMoveFileExWへMOVEFILE_REPLACE_EXISTINGを渡す", + "通常openはFILE_SHARE_READ/WRITE/DELETEを渡す" + ], + "product_cause": "Windows CIのEPERMとの因果は未解明。読取だけを原因と断定しない。", + "related_spec": [ + "https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw", + "https://devblogs.microsoft.com/oldnewthing/20211022-00/?p=105822/" + ] +} diff --git a/rag/parent-delivery/windows-rename-ci-diagnosis.json b/rag/parent-delivery/windows-rename-ci-diagnosis.json new file mode 100644 index 00000000..0841cc09 --- /dev/null +++ b/rag/parent-delivery/windows-rename-ci-diagnosis.json @@ -0,0 +1,21 @@ +{ + "schema": "peertable.parent-windows-rename-diagnosis.v1", + "source_commit": "c97b431cbcb75e07a45cc17de6d688442b736af3", + "runtime_digest": "ddaadee7f33139c2a7ea8d08b36d87446b3221b85c817b9a3e1afff34193186f", + "ci_url": "https://github.com/kitepon/peertable/actions/runs/36485234948", + "os": "win32", + "node": "v24.20.0", + "test": "複数processのclaim競合は1通だけを所有し、孤児sendingはunknownで再送しない", + "error_code": "EPERM", + "syscall": "rename", + "operation": "Bakery ticketのchoosing更新をatomicJsonで置換", + "source_file": "skill/scripts/parent-platform.mjs", + "observed_failure": true, + "cause": "未解明", + "product_live": false, + "formal_acceptance_credit": 0, + "raw_log": "/private/tmp/peertable-windows-rename-c97b431/ci-failed.log", + "raw_log_sha256": "dbbc60e991f86efaeaf99c3008ae9057cc3e1c4d6373a60507971db5cf8272d8", + "baseline_same_runtime_ci": "https://github.com/kitepon/peertable/actions/runs/36484812856", + "diagnosis_status": "Windowsのfocused実再現とOS境界の原因照合中。rerunだけで解決としない。" +} From da4af4e521a42bdc0805aed535bcdb1025a6d828 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:30:07 +0900 Subject: [PATCH 63/72] =?UTF-8?q?RPC=E8=A6=B3=E6=B8=AC=E3=81=A7UTF8?= =?UTF-8?q?=E3=81=AE=E5=8E=9Fbytes=E3=81=A8=E5=88=86=E5=89=B2=E3=82=92?= =?UTF-8?q?=E4=BF=9D=E6=8C=81=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scenarios-queue-fault.mjs | 15 +++++++++++---- .../scenarios-queue-fault.test.mjs | 4 ++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/experiments/parent-product-acceptance/scenarios-queue-fault.mjs b/experiments/parent-product-acceptance/scenarios-queue-fault.mjs index 20074cc1..670160e0 100644 --- a/experiments/parent-product-acceptance/scenarios-queue-fault.mjs +++ b/experiments/parent-product-acceptance/scenarios-queue-fault.mjs @@ -26,10 +26,17 @@ export function codexProbeJoinBoundary({ file, session, project, name, endpointI // chunk分割を保持した原logから、改行で確定した公式JSON-RPCだけを復元する。 export function queueFaultRpcRows(events) { - const buffers = new Map(), rows = [] + const buffers = new Map(), decoders = new Map(), rows = [] for (const event of events) { if (!['rpc_write_raw', 'rpc_read_raw'].includes(event.kind)) continue - const key = `${event.pid}:${event.child_pid}:${event.kind}`, text = (buffers.get(key) ?? '') + event.raw + const key = `${event.pid}:${event.child_pid}:${event.kind}` + let decoded = event.raw + if (event.raw_base64 !== undefined) { + if (!decoders.has(key)) decoders.set(key, new TextDecoder('utf-8', { fatal: true })) + try { decoded = decoders.get(key).decode(Buffer.from(event.raw_base64, 'base64'), { stream: true }) } + catch { fail('ACCEPTANCE_QUEUE_FAULT_RPC_UTF8_INVALID', '公式RPCの原bytesがUTF-8ではありません') } + } + const text = (buffers.get(key) ?? '') + decoded const lines = text.split('\n'); buffers.set(key, lines.pop()) for (const line of lines) { let row @@ -160,8 +167,8 @@ if (configPath && existsSync(configPath)) { const childOwner = config.pause_probe ? null : platform.processIdentity(child.pid) log('official_connection_spawn', { child_pid: child.pid, child_owner: childOwner, executable, spawn_argv: argv, owned_fault: Boolean(proof), owned_queue: Boolean(queueProof), ...queueProof }) const write = child.stdin.write - child.stdin.write = function (...writeArgs) { log('rpc_write_raw', { child_pid: child.pid, raw: Buffer.isBuffer(writeArgs[0]) ? writeArgs[0].toString('utf8') : writeArgs[0] }); return Reflect.apply(write, this, writeArgs) } - child.stdout.on('data', chunk => log('rpc_read_raw', { child_pid: child.pid, raw: chunk.toString('utf8') })) + child.stdin.write = function (...writeArgs) { const bytes = Buffer.isBuffer(writeArgs[0]) ? writeArgs[0] : Buffer.from(writeArgs[0], typeof writeArgs[1] === 'string' ? writeArgs[1] : 'utf8'); log('rpc_write_raw', { child_pid: child.pid, raw: bytes.toString('utf8'), raw_base64: bytes.toString('base64') }); return Reflect.apply(write, this, writeArgs) } + child.stdout.on('data', chunk => log('rpc_read_raw', { child_pid: child.pid, raw: chunk.toString('utf8'), raw_base64: chunk.toString('base64') })) child.on('close', (code, signal) => log('official_connection_closed', { child_pid: child.pid, child_owner: childOwner, code, signal, child_identity_alive: childOwner ? Boolean(platform.sameProcess(childOwner)) : null })) if (proof) { process.kill(child.pid, 'SIGSTOP'); suspended = true diff --git a/experiments/parent-product-acceptance/scenarios-queue-fault.test.mjs b/experiments/parent-product-acceptance/scenarios-queue-fault.test.mjs index edc765fc..4478e2a9 100644 --- a/experiments/parent-product-acceptance/scenarios-queue-fault.test.mjs +++ b/experiments/parent-product-acceptance/scenarios-queue-fault.test.mjs @@ -26,6 +26,10 @@ test('公式RPCは方向/接続ごとにchunkを連結し、未確定末尾は assert.deepEqual(queueFaultRpcRows([{ ...events[0], raw: raw.slice(0, 7) }, { ...events[0], raw: raw.slice(7) }])[0].row, { id: 1, result: {} }) assert.equal(queueFaultRpcRows([{ ...events[0], raw: raw.slice(0, -1) }]).length, 0) assert.throws(() => queueFaultRpcRows([{ ...events[0], raw: '非JSON\n' }]), { code: 'ACCEPTANCE_QUEUE_FAULT_RPC_CORRUPT' }) + const bytes = Buffer.from(JSON.stringify({ id: 2, result: { text: '原文<&>\r\n' } }) + '\n'), boundary = bytes.indexOf(Buffer.from('原')) + 1 + const byteEvents = [bytes.subarray(0, boundary), bytes.subarray(boundary)].map(chunk => ({ ...events[0], raw: chunk.toString('utf8'), raw_base64: chunk.toString('base64') })) + assert.equal(queueFaultRpcRows(byteEvents)[0].row.result.text, '原文<&>\r\n') + assert.throws(() => queueFaultRpcRows([{ ...events[0], raw_base64: Buffer.from([0xff]).toString('base64') }]), { code: 'ACCEPTANCE_QUEUE_FAULT_RPC_UTF8_INVALID' }) }) test('receiver成功と初回queue受付前を照合し、別CID/不信頼/受付後を拒否する', () => { From 2622374953b1b4cb0199be6d9ac3b5d920262cbb Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:43:41 +0900 Subject: [PATCH 64/72] =?UTF-8?q?=E5=8F=97=E5=85=A5=E3=81=AE=E5=AE=9Fprobe?= =?UTF-8?q?=E6=9C=9F=E9=99=90=E3=81=A8=E5=90=8C=E4=BC=9A=E8=A9=B1=E5=86=8D?= =?UTF-8?q?=E7=99=BB=E9=8C=B2=E5=BE=A9=E6=97=A7=E3=82=92=E7=85=A7=E5=90=88?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scenarios-native.mjs | 59 +++++++++++++++++-- .../scenarios-native.test.mjs | 31 +++++++++- 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/experiments/parent-product-acceptance/scenarios-native.mjs b/experiments/parent-product-acceptance/scenarios-native.mjs index 5542dfdb..768b0d1d 100644 --- a/experiments/parent-product-acceptance/scenarios-native.mjs +++ b/experiments/parent-product-acceptance/scenarios-native.mjs @@ -9,7 +9,7 @@ import { createScenarioContext, completedNativeInput, completedNativeInputProof import { createNativeFixtureFactory, waitOwnedFixtureExit } from './scenarios-fixtures.mjs' import { sha256 } from './evidence.mjs' import { createBackgroundSurfaceAdapters, nativeInvocation } from './scenarios-surfaces.mjs' -import { installQueueProbeFault, queueProbeReceiverProof, watchQueueProbeState, queueProbeTimeoutProof, codexProbeJoinBoundary, installQueueConnectionObserver, observedQueueConnections } from './scenarios-queue-fault.mjs' +import { installQueueProbeFault, queueProbeReceiverProof, watchQueueProbeState, queueProbeTimeoutProof, codexProbeJoinBoundary, installQueueConnectionObserver, observedQueueConnections, QUEUE_PROBE_OBSERVATION_LIMIT_MS } from './scenarios-queue-fault.mjs' const fail = (code, message, detail) => { throw Object.assign(new Error(message), { code, detail }) } const rows = file => existsSync(file) ? readFileSync(file, 'utf8').split('\n').filter(Boolean).map(line => JSON.parse(line)) : [] @@ -77,6 +77,34 @@ export function observedQueueConnectionProof(connections, checks) { return bound } +// 配布物が保存した実期限を使う。30秒/120秒をcontrollerへ複製しない。 +export function probeDeadlineObservationBudget(deadline, now = Date.now()) { + if (!Number.isFinite(deadline)) fail('ACCEPTANCE_QUEUE_PROBE_DEADLINE_MISSING', '実probe_deadlineがありません') + return Math.max(1, deadline - now + QUEUE_PROBE_OBSERVATION_LIMIT_MS) +} + +// 再登録は新しいsynthetic probeだけを解決先とし、旧記録・本文・cursorを保持する。 +export function codexProbeRecoveryProof({ before, after, join, seen, health }) { + const session = before.caller?.conversation + if (before.state !== 'failed' || before.error_code !== 'PARENT_PROBE_TIMEOUT' || !before.probe_id) fail('ACCEPTANCE_QUEUE_RECOVERY_INITIAL_FAILURE_MISSING', '復旧前の実probe期限failureがありません') + if (after.endpoint_id !== before.endpoint_id || after.caller?.conversation !== session || after.caller?.owner?.pid !== before.caller.owner.pid || after.caller?.owner?.started !== before.caller.owner.started || join.session !== session || join.result.endpoint_id !== before.endpoint_id) fail('ACCEPTANCE_QUEUE_RECOVERY_IDENTITY_MISMATCH', '正規再登録の会話/endpoint/親本人が変わっています') + const previous = before.records.find(record => record.event.type === 'parent_probe' && record.event.event_id === `probe:${before.probe_id}`) + const preserved = previous && after.records.find(record => record.delivery_id === previous.delivery_id) + const current = after.records.find(record => record.event.type === 'parent_probe' && record.event.event_id === `probe:${after.probe_id}`) + if (!previous || previous.state !== 'failed' || previous.resolved_by || !current || after.probe_id === before.probe_id || current.delivery_id === previous.delivery_id || current.state !== 'submitted' || !current.queued_submission_id || !current.accepted_at || current.queue_thread !== session) fail('ACCEPTANCE_QUEUE_RECOVERY_NEW_PROBE_MISSING', '失敗probeと異なる新probeの公式受付がありません') + const { resolved_by: resolvedBy, ...retained } = preserved ?? {} + if (!isDeepStrictEqual(previous, retained) || resolvedBy !== current.delivery_id || after.cursor !== before.cursor || before.records.filter(record => record.event.type !== 'parent_probe' && ['failed', 'unknown'].includes(record.state)).some(record => !isDeepStrictEqual(record, after.records.find(item => item.delivery_id === record.delivery_id)))) fail('ACCEPTANCE_QUEUE_RECOVERY_HISTORY_CHANGED', '旧failed原記録/cursorまたは別DM記録が変更されています') + const delivered = seen.deliveries.filter(item => item.delivery_id === current.delivery_id) + const delivery = delivered[0] + if (delivered.length !== 1 || delivery.session !== session || !delivery.turn_id || delivery.body !== current.event.body || delivery.digest !== current.digest || delivery.room !== current.event.room || delivery.from !== current.event.from || delivery.to !== current.event.to || delivery.preview || delivery.order <= join.order) fail('ACCEPTANCE_QUEUE_RECOVERY_LITERAL_MISSING', '新probeの同会話への原文配送が1件に確定しません') + const reply = seen.replies.find(item => item.session === session && item.turn_id === delivery.turn_id && item.order > delivery.order && item.text.includes(after.probe_id)) + const endpoint = health.bridges?.parent_receiver?.endpoints?.find(item => item.endpoint_id === before.endpoint_id) + let detail + try { detail = typeof endpoint?.detail === 'string' ? JSON.parse(endpoint.detail) : endpoint?.detail } catch { fail('ACCEPTANCE_QUEUE_HEALTH_DETAIL_CORRUPT', '公式GETmembersのdetailがJSONではありません') } + if (!reply || after.state !== 'verified' || after.runtime !== 'armed' || after.error_code || health.bridges?.parent_receiver?.state !== 'up' || endpoint?.state !== 'armed' || detail?.endpoint_id !== before.endpoint_id || detail.state !== 'verified' || detail.error_code !== null) fail('ACCEPTANCE_QUEUE_RECOVERY_NOT_VERIFIED', '新probeの後続返答と実spool/healthの復旧が揃いません') + return { related_session: session, endpoint_id: before.endpoint_id, old_probe_id: before.probe_id, new_probe_id: after.probe_id, original_failed_record: previous, preserved_failed_record: preserved, submitted_probe_record: current, official_join: join, literal_delivery: delivery, literal_reply: reply, health_endpoint: endpoint, cursor_preserved: true } +} + // 公式MCP包装のJSONだけを開き、本文の正規化や自由文からのerror推測をしない。 export function productToolError(value, expected) { if (typeof value === 'string') { try { return productToolError(JSON.parse(value), expected) } catch (error) { if (error instanceof SyntaxError) return null; throw error } } @@ -672,7 +700,7 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { own.receiver = queueProbeReceiverProof({ events: own.fault.events(), pause, expectedSource, expectedCommand: s.shellCommand(process.execPath, [join(s.pkg, 'skill/scripts/parent-hook.mjs'), 'codex']) }) own.join = await s.until('同CIDの公式MCP join結果', () => codexProbeJoinBoundary({ file: target.file, session: target.meta.parent_session, project: fixture.project, name: fixture.name, endpointId: target.spool.id }), 15000, 50) writeFileSync(evidenceFile, JSON.stringify({ runner, source: target.meta, pause, receiver: own.receiver, join: own.join }, null, 2)) - return s.result(s, 'own_probe_not_consumed', 'official_queue', { related_session: target.meta.parent_session, turn_id: own.join.turn_id, native_tool_use_id: own.join.native_tool_use_id, endpoint_id: target.spool.id, parent_owner: pause.caller.owner, watcher_owner: pause.watcher, child_owner: pause.owner, probe_deadline: pause.spool_before.probe_deadline, receiver_prepared_before_first_queue: own.receiver, original_artifact: evidenceFile, runner }) + return { ...s.result(s, 'own_probe_not_consumed', 'official_queue', { parent_session: target.meta.parent_session, related_session: target.meta.parent_session, turn_id: own.join.turn_id, native_tool_use_id: own.join.native_tool_use_id, endpoint_id: target.spool.id, parent_owner: pause.caller.owner, watcher_owner: pause.watcher, child_owner: pause.owner, probe_deadline: pause.spool_before.probe_deadline, receiver_prepared_before_first_queue: own.receiver, original_artifact: evidenceFile, runner }), related_sessions: [target.meta.parent_session] } } actions.probe_timeout_observe = async (_, s) => { const own = state(s).probeDeadline @@ -684,17 +712,40 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { health = await own.target.api('members') const endpoint = health.bridges?.parent_receiver?.endpoints?.find(item => item.endpoint_id === own.target.spool.id) return saved.state === 'failed' && saved.runtime === 'failed' && saved.error_code === 'PARENT_PROBE_TIMEOUT' && endpoint?.state === 'failed' - }, 45000, 50) + }, probeDeadlineObservationBudget(own.pause.spool_before.probe_deadline), 50) await s.until('35秒OS停止の自然終了/再開記録', () => own.fault.terminal(), 40000, 100) const evidence = { runner: own.runner, source: own.target.meta, related_session: own.target.meta.parent_session, pause: own.pause, receiver: own.receiver, join: own.join, state_observations: own.stateObserver.rows, health, terminal: own.fault.terminal(), rpc_os_events: own.fault.events(), transcript_raw: readFileSync(own.target.file, 'utf8'), watch_log: readFileSync(join(own.target.spool.root, 'watch.log'), 'utf8') } writeFileSync(own.evidenceFile, JSON.stringify(evidence, null, 2)) // 実測lagを保持し、共通controllerの観測上限だけを適用する。 const proof = queueProbeTimeoutProof({ pause: own.pause, observations: own.stateObserver.rows, health }) - return s.result(s, 'probe_timeout_typed_failed', 'official_queue', { ...proof, related_session: own.target.meta.parent_session, turn_id: own.join.turn_id, original_artifact: own.evidenceFile, runner: own.runner }) + own.failedState = structuredClone(own.target.spool.read()) + return { ...s.result(s, 'probe_timeout_typed_failed', 'official_queue', { ...proof, parent_session: own.target.meta.parent_session, related_session: own.target.meta.parent_session, turn_id: own.join.turn_id, original_artifact: own.evidenceFile, runner: own.runner }), related_sessions: [own.target.meta.parent_session] } } actions.deadline_restore = async (_, s) => { const pending = state(s).deadline if (pending) for (const release of pending.releases) writeFileSync(release, '{}') + const own = state(s).probeDeadline + if (own) { + if (!own.failedState) fail('ACCEPTANCE_QUEUE_RECOVERY_INITIAL_FAILURE_MISSING', '同runの期限failure観測を完了していません') + await own.fault.close() + const beforeOrder = readFileSync(own.target.file, 'utf8').split('\n').length - 2 + const startedAt = new Date().toISOString(), artifact = s.artifactFor(s, 'probe-rejoin-original') + await own.fixture.submit(`Peertable parent_joinをproject=${own.fixture.project} name=${own.fixture.name}で1回だけ呼び、結果を原文で報告してください。同会話の期限障害からの正規復旧です。自動再試行・設定変更はしないでください。`) + const joined = await s.until('同CIDの正規再join公式完了', () => codexProbeJoinBoundary({ file: own.target.file, session: own.target.meta.parent_session, project: own.fixture.project, name: own.fixture.name, endpointId: own.target.spool.id, after: beforeOrder }), 120000, 100) + const target = await own.fixture.refreshTarget() + let snapshot + const proof = await s.until('新probeの原文/後続返答/同endpoint health復旧', async () => { + snapshot = { at: new Date().toISOString(), before: own.failedState, after: target.spool.read(), join: joined, seen: target.observe(), health: await target.api('members') } + writeFileSync(artifact, JSON.stringify({ runner: own.runner, source: target.meta, started_at: startedAt, ...snapshot, rpc_os_events: own.fault.events(), transcript_raw: readFileSync(target.file, 'utf8') }, null, 2)) + if (!snapshot.seen.replies.some(reply => reply.session === target.meta.parent_session && reply.order > joined.order && reply.text.includes(snapshot.after.probe_id)) || snapshot.health.bridges?.parent_receiver?.endpoints?.find(item => item.endpoint_id === target.spool.id)?.state !== 'armed') return null + return codexProbeRecoveryProof(snapshot) + }, 120000, 100) + s.context.registerEndpoint('deadline-original', self(s)) + s.context.registerEndpoint('probe-timeout', target) + s.context.registerEndpoint('self', target) + own.target = target + return { ...s.result(s, 'new_join_verified', 'official_queue', { ...proof, parent_session: target.meta.parent_session, related_turn_id: proof.literal_delivery.turn_id, turn_id: proof.literal_reply.turn_id, runner: own.runner, original_artifact: artifact, subsequent_delivery_endpoint: target.spool.id }), related_sessions: [target.meta.parent_session] } + } const target = await rejoinEndpoint(s) return s.result(s, 'new_join_verified', 'harness_transcript', { parent_session: target.meta.parent_session, endpoint_id: target.spool.id, state: target.spool.read().state }) } diff --git a/experiments/parent-product-acceptance/scenarios-native.test.mjs b/experiments/parent-product-acceptance/scenarios-native.test.mjs index 02c14de7..b5ef45c2 100644 --- a/experiments/parent-product-acceptance/scenarios-native.test.mjs +++ b/experiments/parent-product-acceptance/scenarios-native.test.mjs @@ -6,7 +6,7 @@ import { createInterface } from 'node:readline' import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { competitorProof, queueConnectionProof, observedQueueConnectionProof, monitorQueueConnections, createNativeActions, productToolError, assertOwnedReceiver, assertOwnedResume, pauseOwnedReceiver, nativeLeaseRegistration, bindingContextProof, bindingToolError } from './scenarios-native.mjs' +import { competitorProof, queueConnectionProof, observedQueueConnectionProof, monitorQueueConnections, createNativeActions, productToolError, assertOwnedReceiver, assertOwnedResume, pauseOwnedReceiver, nativeLeaseRegistration, bindingContextProof, bindingToolError, codexProbeRecoveryProof, probeDeadlineObservationBudget } from './scenarios-native.mjs' import { processIdentity, sameProcess, processDescendsFrom } from '../../skill/scripts/parent-platform.mjs' import { scenarioPlan } from './scenarios.mjs' import { digest } from '../../skill/scripts/parent-delivery.mjs' @@ -14,6 +14,35 @@ import { digest } from '../../skill/scripts/parent-delivery.mjs' const event = { session_id: 'own', turn_id: 'turn', hook_event_name: 'PostToolUse', tool_use_id: 'tool' } const pair = (pid, value = event, stdout = '{}', code = 0) => [{ phase: 'started', pid, event: value }, { phase: 'completed', pid, event: value, stdout, code }] +test('probe待機予算は実deadlineから算出し、製品の30秒と120秒を複製しない', () => { + assert.equal(probeDeadlineObservationBudget(31000, 1000), 31500) + assert.equal(probeDeadlineObservationBudget(121000, 1000), 121500) + assert.equal(probeDeadlineObservationBudget(1000, 2000), 500) + assert.equal(probeDeadlineObservationBudget(1000, 3000), 1) + assert.throws(() => probeDeadlineObservationBudget(undefined, 0), { code: 'ACCEPTANCE_QUEUE_PROBE_DEADLINE_MISSING' }) +}) + +test('同CID正規rejoinは新probeの原文/後続返答/healthと旧failed履歴保持を照合する', () => { + // 実CLIの代用にはしない。復旧記録の誤相関と本文の丸めを拒否するfocused。 + const owner = { pid: 10, started: '親開始' }, old = { delivery_id: 'old-delivery', digest: '旧digest', event: { type: 'parent_probe', event_id: 'probe:old', body: '旧<&>\r\n本文' }, state: 'failed' }, dm = { delivery_id: 'unresolved-dm', event: { type: 'message', body: '未知DM' }, state: 'unknown' } + const before = { endpoint_id: 'own', caller: { conversation: 'CID', owner }, cursor: 9, state: 'failed', runtime: 'failed', error_code: 'PARENT_PROBE_TIMEOUT', probe_id: 'old', records: [old, dm] } + const next = { delivery_id: 'new-delivery', digest: '新digest', event: { type: 'parent_probe', event_id: 'probe:new', body: '原<&>\r\nnew', room: 'room', from: 'peertable', to: '親' }, state: 'submitted', queued_submission_id: 'queue', accepted_at: '2026-09-29T00:00:00Z', queue_thread: 'CID' } + const after = { ...structuredClone(before), state: 'verified', runtime: 'armed', error_code: null, probe_id: 'new', records: [{ ...structuredClone(old), resolved_by: next.delivery_id }, structuredClone(dm), next] } + const join = { order: 3, session: 'CID', turn_id: 'join-turn', result: { endpoint_id: 'own', state: 'receiving' } }, delivery = { delivery_id: next.delivery_id, digest: next.digest, ...next.event, session: 'CID', turn_id: 'probe-turn', order: 4 }, reply = { session: 'CID', turn_id: 'probe-turn', order: 5, text: 'newを受信' } + const health = { bridges: { parent_receiver: { state: 'up', endpoints: [{ endpoint_id: 'own', state: 'armed', detail: JSON.stringify({ endpoint_id: 'own', state: 'verified', error_code: null }) }] } } }, value = { before, after, join, seen: { deliveries: [delivery], replies: [reply] }, health } + assert.equal(codexProbeRecoveryProof(value).literal_delivery.body, next.event.body) + const reject = (edit, code) => { const candidate = structuredClone(value); edit(candidate); assert.throws(() => codexProbeRecoveryProof(candidate), { code }) } + reject(v => { v.after.probe_id = 'old' }, 'ACCEPTANCE_QUEUE_RECOVERY_NEW_PROBE_MISSING') + reject(v => { v.after.caller.owner.started = '再利用PID' }, 'ACCEPTANCE_QUEUE_RECOVERY_IDENTITY_MISMATCH') + reject(v => { v.after.records[0].event.body = '旧<&>\n本文' }, 'ACCEPTANCE_QUEUE_RECOVERY_HISTORY_CHANGED') + reject(v => { v.after.records[1].resolved_by = next.delivery_id }, 'ACCEPTANCE_QUEUE_RECOVERY_HISTORY_CHANGED') + reject(v => { v.after.cursor++ }, 'ACCEPTANCE_QUEUE_RECOVERY_HISTORY_CHANGED') + reject(v => { v.seen.deliveries[0].body = '原<&>\nnew' }, 'ACCEPTANCE_QUEUE_RECOVERY_LITERAL_MISSING') + reject(v => { v.seen.deliveries.push(v.seen.deliveries[0]) }, 'ACCEPTANCE_QUEUE_RECOVERY_LITERAL_MISSING') + reject(v => { v.seen.replies[0].turn_id = '別turn' }, 'ACCEPTANCE_QUEUE_RECOVERY_NOT_VERIFIED') + reject(v => { v.health.bridges.parent_receiver.state = 'failed' }, 'ACCEPTANCE_QUEUE_RECOVERY_NOT_VERIFIED') +}) + test('競合証拠は同eventの異なる実processに限定し、event合算と二重完了を拒否する', () => { const options = { session: 'own', delivery: { turn_id: 'turn' } } assert.equal(competitorProof([...pair(1), ...pair(2)], options).starts.length, 2) From 980cbebc1acab7461daa064abec8814b410f0137 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:52:23 +0900 Subject: [PATCH 65/72] =?UTF-8?q?=E8=A6=AAprobe=E3=81=AE=E5=AE=9F=E6=99=82?= =?UTF-8?q?=E9=96=93=E3=81=AB=E6=9C=9F=E9=99=90=E3=82=92=E5=90=88=E3=82=8F?= =?UTF-8?q?=E3=81=9BWindows=E3=81=AEatomic=E7=BD=AE=E6=8F=9B=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E7=90=86=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/current-design.md | 2 + docs/plan_parent-native-delivery.md | 8 +- package-lock.json | 363 +++++++++++++++++- package.json | 3 +- rag/INDEX.md | 5 +- rag/parent-delivery/cli-lifecycle-repairs.md | 10 +- .../codex-probe-rejoin-repair-diagnosis.json | 48 +++ .../linux-grok-probe-latency-diagnosis.json | 85 ++++ .../release-candidate-checkpoint.json | 18 +- .../windows-posix-rename-api.metadata.json | 38 ++ .../windows-rename-ci-diagnosis.json | 266 ++++++++++++- scripts/run-product-ci.mjs | 2 +- skill/scripts/parent-delivery.mjs | 4 +- skill/scripts/parent-delivery.test.mjs | 4 +- skill/scripts/parent-platform-atomic.test.mjs | 35 ++ skill/scripts/parent-platform.mjs | 4 +- skill/scripts/parent-runtime-probe.test.mjs | 4 +- skill/scripts/parent-runtime.mjs | 4 +- skill/scripts/parent-windows-files.mjs | 43 +++ 19 files changed, 923 insertions(+), 23 deletions(-) create mode 100644 rag/parent-delivery/codex-probe-rejoin-repair-diagnosis.json create mode 100644 rag/parent-delivery/linux-grok-probe-latency-diagnosis.json create mode 100644 rag/parent-delivery/sources/windows-posix-rename-api.metadata.json create mode 100644 skill/scripts/parent-platform-atomic.test.mjs create mode 100644 skill/scripts/parent-windows-files.mjs diff --git a/docs/current-design.md b/docs/current-design.md index 2efa4deb..56fba84d 100644 --- a/docs/current-design.md +++ b/docs/current-design.md @@ -83,6 +83,8 @@ setup、resume、teardownはそれぞれ一回の製品入口で必要な順序 roomは解散状態(archive)を持つ。teardownがroomをarchiveし、公開一覧(`/api/rooms`のrooms欄とトップページ主一覧)から外す。個別ページとログAPIは読めるまま残る。次のsetupのmember登録が同じroomを自動で現役へ戻す。公開面に並ぶのは現役の卓だけである。 +親のspoolと排他ticketは同じatomic置換で保存する。Windowsは標準のPOSIX互換rename APIへ適合し、旧fileの読取りhandleを保ったまま新しいpathの全量を切り替える。OS APIの失敗は原errorを持つtyped errorで返す。 + 永続PTYとharness起動はAitermの公開APIを使う。Windows native shellはPowerShell 7を正とし、OS差分は環境別adapterへ閉じ込める。 session IDは公開`AITERM_SESSION_ID`とMCP応答から取得する。`pty_list`の明示envと`pty_observe`の状態・本人性・活動差分を使い、内部state・namespace・socket・画面分類をPeertableへ複製しない。起動準備は`agent_launch`の`startup.ready`、承認は公開approval API、メンバーへの入力は統合`pty_send`のreceiptで確認する。Aitermが実行中への差し込みと待機中の新規ターンを選ぶ。API不足は再現と必要契約をAitermへ渡して解消する。 diff --git a/docs/plan_parent-native-delivery.md b/docs/plan_parent-native-delivery.md index 0a676a9b..abb6c703 100644 --- a/docs/plan_parent-native-delivery.md +++ b/docs/plan_parent-native-delivery.md @@ -94,7 +94,7 @@ MCP requestと公式hookから実際の親を識別する。AIにthread IDやcon `parent_join`のreceiptは`schema: peertable.parent-join-result.v1`、`endpoint_id`、`state: binding_pending | receiving | verified | failed`、`harness`、`wait_process`、`error_code`を持つ。Cursor/Grokの結果返却後にhookで束縛する場合、最初のreceiptは`binding_pending`であり、成功やreadyと書かない。hookが返却結果の`endpoint_id`を実会話へ束縛した時点で`receiving`へ進み、購読とprobe配送を開始する。probeがその会話の受信経路を通った後に`verified`へ進める。`verified`を配送開始の条件にしてprobeを止める循環を作らない。進行は製品が続け、オーナーに手順の再実行を求めない。 -現行のClaude/Cursor/Grokは事前hookで実会話と入力digestを照合し、照合記録が30秒を超えた場合はendpoint作成前に`PARENT_BIND_TIMEOUT`で失敗する。Codexは公式MCPのthread metadataと実processを直接照合する。事前hookで実会話を照合できたjoinは、返却時点で`receiving`にできる。事後hookで初めて束縛するjoinだけが`binding_pending`を返す。旧watcherからの移行中は、束縛とreceiver準備が済んでも旧本文出力の停止が確認されるまで新watchとprobeを開始しない。束縛待ち・probe待ちはそれぞれ30秒を初期の明示期限とし、前者は`PARENT_BIND_TIMEOUT`、後者は`PARENT_PROBE_TIMEOUT`で失敗を残す。probe期限はnative receiverの準備確認後から計る。Codexの初回joinも公式receiver検証後、watcher起動前に期限を設定する。watcherはspoolのatomic更新を監視し、実期限へtimerを張る。期限到達はroomの心拍や公式RPCの完了を待たず、最新状態の成功・再武装・停止を照合してからfailedとhealthを記録する。背景toolの登録前は、必要な登録入力を伴う`rearm_pending`として表示する。Windows Grokの実測ではモデルが登録toolを呼ぶまで数分かかり、join時点から計る旧実装の期限とは目的が異なった。遅れて確認された現在のprobeは、そのprobeの期限エラーだけを解除する。実機の開始時間を第1工程で測り、必要なら根拠を残して期限を調整する。無期限のpendingにしない。 +現行のClaude/Cursor/Grokは事前hookで実会話と入力digestを照合し、照合記録が30秒を超えた場合はendpoint作成前に`PARENT_BIND_TIMEOUT`で失敗する。Codexは公式MCPのthread metadataと実processを直接照合する。事前hookで実会話を照合できたjoinは、返却時点で`receiving`にできる。事後hookで初めて束縛するjoinだけが`binding_pending`を返す。旧watcherからの移行中は、束縛とreceiver準備が済んでも旧本文出力の停止が確認されるまで新watchとprobeを開始しない。束縛待ちは30秒、probe待ちは全OS・全ハーネス共通の`PARENT_PROBE_TIMEOUT_MS`(120秒)を明示期限とし、前者は`PARENT_BIND_TIMEOUT`、後者は`PARENT_PROBE_TIMEOUT`で失敗を残す。probe期限はnative receiverの準備確認後から計る。Codexの初回joinも公式receiver検証後、watcher起動前に期限を設定する。watcherはspoolのatomic更新を監視し、実期限へtimerを張る。期限到達はroomの心拍や公式RPCの完了を待たず、最新状態の成功・再武装・停止を照合してからfailedとhealthを記録する。背景toolの登録前は、必要な登録入力を伴う`rearm_pending`として表示する。Windows Grokの実測ではモデルが登録toolを呼ぶまで数分かかり、join時点から計る旧実装の期限とは目的が異なった。遅れて確認された現在のprobeは、そのprobeの期限エラーだけを解除する。実機の開始時間を第1工程で測り、必要なら根拠を残して期限を調整する。無期限のpendingにしない。 登録の疎通実績と現在の受信継続は別に記録する。runtimeの受信状態は`armed | rearm_pending | stopped | failed`とし、背景taskが終わって次の待機が登録されるまでを`rearm_pending`として診断へ出す。Cursor/Grokではnative背景toolの呼出しと結果を公式hookで相関し、task ID・受信process identity・endpointを保存する。receiptを生成しただけでは`armed`にしない。再登録までに届いた本文は配送記録へ保持し、次の待機が拾う。永久に再登録されない状態を健康と表示しない。 @@ -398,4 +398,8 @@ controllerはCursorの公式postToolUseが確定した入力全体を照合す 正式probe期限試験のcontrollerは、製品の実30秒期限と失敗の観測時刻を分ける。全OS・全harnessに共通の観測上限は既存focused timer試験と同じ1500msとし、失敗の実lagを原値で保存する。期限前、時刻欠落、health不一致、上限超過をtyped failureにし、製品deadlineを延ばして合格へ丸めない。Windowsの所有child停止APIと各harnessの故障発火が実測されていない行は、引き続き未合格とする。 -再登録修理後のWindows CIでは、Bakery ticketのatomic置換で実`EPERM`が発生した。[原記録](../rag/parent-delivery/windows-rename-ci-diagnosis.json)を保持し、同runtimeの先行CI成功を今回の失敗解決へ代用しない。Linux Grokの初回probeとWindows Cursorのlock timeoutも撤回候補のrawを保ち、原因を照合する。現行候補の正式配送試験と公開は、その境界の再現調査中である。 +再登録修理後のWindows CIでは、Bakery ticketのatomic置換で実`EPERM`が発生した。[原記録とAPI対照](../rag/parent-delivery/windows-rename-ci-diagnosis.json)から読取りhandle保持中の通常renameを再現し、Windows標準のPOSIX互換APIで修理した。診断packのfocused試験は合格したが、新候補の3 OS CIは未完了。Linux Grokの初回probeは正常な本文回収時間を測って期限を調整した。Windows Cursorのlock timeoutは別に原因照合が残る。正式配送試験と公開は、新候補でその境界を確認してから進める。 + +[Linux Grokの正規通知の実診断](../rag/parent-delivery/linux-grok-probe-latency-diagnosis.json)では、本文回収まで53.198秒、次の受信登録まで71.99秒かかった。初回probe期限を共通120秒へ調整し、束縛期限を維持する。期限失敗を成功へ数えず、新候補で正常な確認と実期限失敗の両方を再測定する。 + +[Windowsの保持reader対照](../rag/parent-delivery/windows-rename-ci-diagnosis.json)で、libuvの通常置換APIが共有DELETE付きでもACCESS_DENIEDとなり、POSIX互換置換APIでは同じ保持reader下で成立することを確認した。Windowsのatomic更新だけを標準APIへ適合し、排他・spool・receiptの仕組みは全OS共通に保つ。診断packのfocused31件は合格したが、新Git候補の3 OS CIと正式実機は未完了。 diff --git a/package-lock.json b/package-lock.json index 7c035019..b2b6de26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,14 +7,16 @@ "": { "name": "peertable", "version": "0.8.63", + "hasInstallScript": true, "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0" + "@modelcontextprotocol/sdk": "^1.0.0", + "koffi": "3.3.2" }, "bin": { + "peertable": "skill/scripts/cli.mjs", "peertable-client": "room/client.mjs", - "peertable-room": "room/server.mjs", - "peertable": "skill/scripts/cli.mjs" + "peertable-room": "room/server.mjs" }, "devDependencies": { "parse-srcset": "1.0.2", @@ -25,8 +27,7 @@ }, "engines": { "node": ">=24" - }, - "hasInstallScript": true + } }, "node_modules/@hono/node-server": { "version": "2.1.0", @@ -40,6 +41,326 @@ "hono": "^4" } }, + "node_modules/@koromix/koffi-android-arm64": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-android-arm64/-/koffi-android-arm64-3.3.2.tgz", + "integrity": "sha512-6mFtwFJCy1skaf/qzl82OtELmT2BwHXbephfrIFqKoxIghc7N+kiW33J3ZNRP8JhBYFI6xJ8jiatGwPJ7mCOMw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-android-x64": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-android-x64/-/koffi-android-x64-3.3.2.tgz", + "integrity": "sha512-wzIWXvldodHWST6KZBdjJlpdH0DJ2rIN5YppY+/T+mTR1DcRYdYhOXzZMGDDx9W3ao8Hry8G4GqVzK9IuYpuWA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-arm64": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.3.2.tgz", + "integrity": "sha512-gQKOkA/4tkOxXP3hdau2MrvVvTYlKQiXd86hK+B2FKErV0QcwqrWt6DWZ3FtJUYGB1scenT0srS9VfsWmbgeMQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-x64": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.3.2.tgz", + "integrity": "sha512-qxysFokAwiMLiWYYVhooWZHSV3REKJErsUjnX9IeG2rAVpWNkdcXUiQ2GMdfH3hZ0f2fuv0fTuv293/tYu1yew==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-arm64": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.3.2.tgz", + "integrity": "sha512-EinnZVzK3KVTQe0N+XXnUfUN2fwNiYEY5IuAcMqtJO4rbOR+8S+C+bbViUKwqmkLG8OvL/kBesuwGLn5DLNZCQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-ia32": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.3.2.tgz", + "integrity": "sha512-gnJFMVbxeyY4dSQbw0p3smSyFGDrp8QzI5krF73QxP7GSIMHR2lplejZseNQGyHIeR3XDdxuJFsfAfXmZdQM3w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-x64": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.3.2.tgz", + "integrity": "sha512-56mIFadXceWImQbsO0fN1hoZ76LCIvAjouL4fntoA7IKDI64BFbLSkcJ7Roww31YwxKjJaA9vdk93qt7bXXJZw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm/-/koffi-linux-arm-3.3.2.tgz", + "integrity": "sha512-NhvFzykswakToxAWVhpHf5qA/y8z755MAz4MU1m89yIL1lqUuJDSk6Fy/moD3YIQPOjjuHVf09wO7mlO5ukCbw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm64": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.3.2.tgz", + "integrity": "sha512-adabETUHezNDJ6jpqpJV01oJRW8rI4KWH3xV6EpFlIAXCtj6l4aLCbJ6DbGH8fNmsJBLnWiuLkyoNm8zkVotCw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ia32": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.3.2.tgz", + "integrity": "sha512-IMJAp4dg+l1N5Yek43NpDHDVaJFRkMaBnAg9uI3Z1vk61+WxxtaugOl0lC6gDPLBeNE+lzSXcAEeKBIkTwDzdw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-loong64": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.3.2.tgz", + "integrity": "sha512-Sdf+iQ5Fo3Px6e8xFx7eTES+cAIiHxCLt9KDVmnHm0ls6fW2eTVu5ez1+3GUBb/gjLTMODX2U4+dP9LHagwHHg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ppc64": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ppc64/-/koffi-linux-ppc64-3.3.2.tgz", + "integrity": "sha512-FVHtjljl9vUvT6dSsAE1ObOqmPeJXzS8Ez7cidlsI/rJ5pa7h50KQutU/jPfTaK1cqChRjI66hwnJseLG8VPTA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-riscv64": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.3.2.tgz", + "integrity": "sha512-4Q3haXJ3oP5AJ0vlyd9HZsI7gS+s+fycqz1CDDrT6JftlSruIUbfr8be1/jetUlmh7Pd8HtQBM6x2gpQBNTttQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-x64": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.3.2.tgz", + "integrity": "sha512-78qO1oqd9GYfzcKOxIaRiN0KgvzQzgbPfdClf0/jErlWYdxnr8gB4i9KOFvsYMdnc7kTIa/wnubl1lPCiQnqvA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-arm64": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-arm64/-/koffi-openbsd-arm64-3.3.2.tgz", + "integrity": "sha512-rB6HZ/RnEAu7CZoD8IMXonCJSD6WJIX8QfZ11RCOy9MXxA8wQR5CRnP3ffy4/8IbABorRoERLLFTFk5wFFZqCw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-ia32": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.3.2.tgz", + "integrity": "sha512-XmzyeUEsCc8f6n26684mzn73I5VdDBxFydm8JxwYCmvrzfkSkDPU7eQyRoxHDRwgveIK/plmRiMUIj3/7J1flw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-x64": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.3.2.tgz", + "integrity": "sha512-Bt+beMlH6yK4X21+y7lgk+lthjBODfwJjREFITVQH68v8rHWZ5UxV7FBInHZ7ZT0o07G/fgIr2l1DG6SpaTbEA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-arm64": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.3.2.tgz", + "integrity": "sha512-+fLooyTbldECuFH54h1ags7BAOB4fmqnshyVkXr+HbJBhgHdHoIbZ+CcaoH1ELVth7L7G+5XNwbXDH1TOyQMvA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-ia32": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.3.2.tgz", + "integrity": "sha512-knegFlqbXsYfWcbIsAHMrKHqVbLctqzYD9xW3+GfO1Dyt+xe1xY8YDiadjRaWtKkLz5uF1aJD886EfBZUhECLA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-x64": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.3.2.tgz", + "integrity": "sha512-OWc6s6gziEabkQj+xc+ZNaf5g69hc/DQJLBcmbhxJAifAdXDgj9vB5d8hcjS2mTHjBeAIqQ4nkd9d/Jc7OUuHw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", @@ -851,6 +1172,38 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/koffi": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.3.2.tgz", + "integrity": "sha512-f9Rrcmpy7lQs0ayJfhaxiG5OLp1aZjXKOFLXo7Gax6snJRKmdpYnYEOkpQ7ImCBrbvOdzFT06EP+FaLZv35W7Q==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://liberapay.com/Koromix" + }, + "optionalDependencies": { + "@koromix/koffi-android-arm64": "3.3.2", + "@koromix/koffi-android-x64": "3.3.2", + "@koromix/koffi-darwin-arm64": "3.3.2", + "@koromix/koffi-darwin-x64": "3.3.2", + "@koromix/koffi-freebsd-arm64": "3.3.2", + "@koromix/koffi-freebsd-ia32": "3.3.2", + "@koromix/koffi-freebsd-x64": "3.3.2", + "@koromix/koffi-linux-arm": "3.3.2", + "@koromix/koffi-linux-arm64": "3.3.2", + "@koromix/koffi-linux-ia32": "3.3.2", + "@koromix/koffi-linux-loong64": "3.3.2", + "@koromix/koffi-linux-ppc64": "3.3.2", + "@koromix/koffi-linux-riscv64": "3.3.2", + "@koromix/koffi-linux-x64": "3.3.2", + "@koromix/koffi-openbsd-arm64": "3.3.2", + "@koromix/koffi-openbsd-ia32": "3.3.2", + "@koromix/koffi-openbsd-x64": "3.3.2", + "@koromix/koffi-win32-arm64": "3.3.2", + "@koromix/koffi-win32-ia32": "3.3.2", + "@koromix/koffi-win32-x64": "3.3.2" + } + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", diff --git a/package.json b/package.json index 9d96d871..011f041e 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,8 @@ "node": ">=24" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0" + "@modelcontextprotocol/sdk": "^1.0.0", + "koffi": "3.3.2" }, "devDependencies": { "parse-srcset": "1.0.2", diff --git a/rag/INDEX.md b/rag/INDEX.md index c1d51398..3542c44e 100644 --- a/rag/INDEX.md +++ b/rag/INDEX.md @@ -13,5 +13,8 @@ - [Codex probe timer修理後の実診断](parent-delivery/codex-probe-timer-repair-diagnosis.json) — 同じOS障害で実期限10ms後のroom failedを観測し、生timeline・PID・Git blobを独立照合。正式受入への算入は0件。 - [Codex同会話での再登録失敗](parent-delivery/codex-probe-rejoin-diagnosis.json) — 自然timeout後の正規再登録でもfailed probeが不変となる実再現。開始時controller bytesと製品Git blobを照合し、正式合格へ算入しない。 -- [Windows競合CIのrename失敗](parent-delivery/windows-rename-ci-diagnosis.json) — transaction ticketの実EPERMを保存し、原因をfocused再現で調査中。[実Node版のlibuv一次原文](parent-delivery/sources/node-v24.20.0-libuv-win-fs.metadata.json)は原因を断定する証拠と分ける。 +- [Windows競合CIのrename失敗](parent-delivery/windows-rename-ci-diagnosis.json) — transaction ticketの実EPERMと保持readerのAPI対照を保存し、Windows標準APIで修理。[実Node版のlibuv一次原文](parent-delivery/sources/node-v24.20.0-libuv-win-fs.metadata.json)は原因を断定する証拠と分ける。 - [Claude同会話のhook無効化・復帰](parent-delivery/claude-own-session-hook-diagnosis.json) — 公式session settingsと同CIDの3 MCP結果を原transcriptへ独立照合。復帰後の本文配送はこの準備診断では検証していない。 + +- [正常なLinux Grokのprobe遅延](parent-delivery/linux-grok-probe-latency-diagnosis.json) — 正規本文回収53.198秒と健康復旧の原記録、共通期限の調整根拠。 +- [Codex同会話再登録の修理診断](parent-delivery/codex-probe-rejoin-repair-diagnosis.json) — 新probe原文・後続DM・health復旧と旧failed保持の独立照合。正式受入へ算入しない。 diff --git a/rag/parent-delivery/cli-lifecycle-repairs.md b/rag/parent-delivery/cli-lifecycle-repairs.md index e3578dc3..bef04914 100644 --- a/rag/parent-delivery/cli-lifecycle-repairs.md +++ b/rag/parent-delivery/cli-lifecycle-repairs.md @@ -60,8 +60,16 @@ Cursorのidle確認には[公式stop仕様](https://cursor.com/docs/hooks#stop) [実診断](codex-probe-rejoin-diagnosis.json)は初回app-serverを1回だけOS停止し、製品の自然timeoutと期限失敗の後に、同じ会話から正規`parent_join`を1回呼んだ。watcher本人とendpointは維持されたが、失敗probeのIDと記録が不変で、更新した30秒の期限でも再度failedになった。製品state・時計・RPC応答を変更していない。rootは[開始時bytesと製品Git blob](codex-probe-rejoin-fingerprints.json)を照合した。実装継続で変更されたcontrollerについては、診断開始時に保存したbytesを照合対象にした。 -`startEndpoint`の生存watcherによる早期returnより前にprobeを保存し、明示joinでは現在の既知failed probeだけを新しい符号へ更新する。新probeの実受信完了後に旧failed synthetic probeへ`resolved_by`を追加する。原文・失敗状態・cursorを保持し、結果不明のprobeやDMの失敗を解決しない。配送29件、実join内部契約1件、watcher継続3件の関連focused試験が合格した。修正版の正規MCPによる再登録・新probe受信・後続DM・healthは再測定待ちであり、正式合格を増やしていない。 +`startEndpoint`の生存watcherによる早期returnより前にprobeを保存し、明示joinでは現在の既知failed probeだけを新しい符号へ更新する。新probeの実受信完了後に旧failed synthetic probeへ`resolved_by`を追加する。原文・失敗状態・cursorを保持し、結果不明のprobeやDMの失敗を解決しない。配送29件、実join内部契約1件、watcher継続3件の関連focused試験が合格した。[修正版の実診断](codex-probe-rejoin-repair-diagnosis.json)で、正規MCPの同CID再登録、新probeの原文と後続返答、後続DMとreceipt、up/armed/verifiedのhealth復旧を確認した。rootは原transcript、spool、GETmembers、開始時factory bytesを照合した。旧spawn診断を使った準備確認であり、新正式adapterの合格を増やしていない。 ## Claudeの自己会話でのhook故障 [専用診断](claude-own-session-hook-diagnosis.json)は公式`--settings`の`disableAllHooks`だけで、同じ会話の自己CLIを停止・resumeした。正常joinと確認符号のモデル返答、無効化中の`PARENT_CALLER_UNBOUND`、復帰後の同endpoint・`verified`を、同CIDの公式MCP tool IDと対応するuser tool_resultへrootが独立照合した。専用controllerはglobalのbytes不変更を報告しているが、rootの独立監査は保存済み原transcriptと開始関数へ限定する。復帰後の新本文配送はこの診断では試していない。撤回候補の準備診断であり、正式受入へ算入しない。 + +## 正常な背景通知に必要な確認期限 + +[Linux Grokの実診断](linux-grok-probe-latency-diagnosis.json)では正規wake・reader・parent_readまで53.198秒かかり、旧30秒の実期限が先に失敗を記録した。終了時のroomもup/armed/verifiedに復旧していた。初回probe期限は共通定数で120秒へ調整し、本人束縛の30秒は維持する。修正版の実機受入は別に行う。 + +## Windowsの保持readerとatomic置換 + +[実APIと製品packの対照](windows-rename-ci-diagnosis.json)では、通常renameが保持reader下で失敗し、POSIX互換renameで旧fd保持と新pathの置換が成立した。製品のOS adapterはWindows標準APIを直接呼び、PowerShellの起動を更新ごとに挟まない。API非対応や権限失敗は原Windows errorを保持して返す。新候補の3 OS CIと正式実機は未完了。 diff --git a/rag/parent-delivery/codex-probe-rejoin-repair-diagnosis.json b/rag/parent-delivery/codex-probe-rejoin-repair-diagnosis.json new file mode 100644 index 00000000..6ade3072 --- /dev/null +++ b/rag/parent-delivery/codex-probe-rejoin-repair-diagnosis.json @@ -0,0 +1,48 @@ +{ + "schema": "peertable.parent-probe-rejoin-repair-diagnosis.v1", + "source_commit": "c97b431cbcb75e07a45cc17de6d688442b736af3", + "runtime_digest": "ddaadee7f33139c2a7ea8d08b36d87446b3221b85c817b9a3e1afff34193186f", + "pack_sha256": "823336e4397dabc77001d03dc57089822e1b3e48fbd2b4248623323007796329", + "product_live": false, + "formal_acceptance_credit": 0, + "root_raw_audit": true, + "controller_factory_git_blob": "e8de1ec:experiments/parent-product-acceptance/scenarios-fixtures.mjs", + "controller_factory_exact": true, + "parent_session": "01a0e9ed-92a4-7dd2-9926-f008f2f1e15e", + "endpoint_id": "3c149bd3-559a-4007-83d8-c4ed84c442b6", + "old_probe_id": "3c5cd81d-74e0-4727-ad7a-c064128846f5", + "new_probe_id": "2453117f-639c-415c-ba4c-106b60226be3", + "old_failed_body_and_state_preserved": true, + "new_probe_literal_complete": true, + "subsequent_dm_body_exact": true, + "subsequent_dm_receipt": "delivered", + "final_health": { + "state": "up", + "endpoints": [ + { + "recipient": "fixture-2a9c881a", + "endpoint_id": "3c149bd3-559a-4007-83d8-c4ed84c442b6", + "state": "armed", + "beat_at": "2026-09-28T21:32:21.040Z", + "detail": "{\"endpoint_id\":\"3c149bd3-559a-4007-83d8-c4ed84c442b6\",\"state\":\"verified\",\"error_code\":null}" + } + ] + }, + "cleanup": { + "fixture_closed": true, + "server_gone": true, + "errors": [] + }, + "limitations": [ + "診断用の既存spawn observerを使用し、新正式adapterの実機合格には算入しない", + "診断sourceはWindows CIの保存エラーが残り正式受入を保留" + ], + "private_raw": "/Users/kite/.codex/worktrees/parent-cli-scenarios/peertable/.lease-execution-017-b639/diagnostics/codex-probe-rejoin-c97/", + "fingerprints": { + "result.json": "f553bc135019e802da67d2ed56540d18a8c6d291991ca20f3647f9a954830318", + "own-transcript.jsonl": "7e19864caf0b57b3cf45c98a3a773b33600447a4c31bc9d808c82e4637fc8288", + "factory-at-start.mjs.txt": "3ba297bb65ec16a4b94164df4c748dcb965d0b5ba56b3b6dcf277150e2edda8f", + "diagnostic-summary.json": "ab347c70816f95e961e66e4ff1ceedf3e4c2377323274b97ec46574130e47f0a", + "rpc-os-raw.jsonl": "d7a0e5060c75e47e47d557ffc3df83b174dd6deca5462775d71146c3f4f2c2e2" + } +} diff --git a/rag/parent-delivery/linux-grok-probe-latency-diagnosis.json b/rag/parent-delivery/linux-grok-probe-latency-diagnosis.json new file mode 100644 index 00000000..eaf5e8cc --- /dev/null +++ b/rag/parent-delivery/linux-grok-probe-latency-diagnosis.json @@ -0,0 +1,85 @@ +{ + "schema": "peertable.parent-grok-probe-latency-diagnosis.v1", + "product_source": "84de74a097469ef4abd43698635805696b1302ff", + "runtime_digest": "778b09708b12cdd808a1d7d8451aad5b34131ec9b5207f7bb50e4313f525ee7f", + "product_live": false, + "formal_acceptance_credit": 0, + "root_raw_audit": true, + "os": "linux", + "harness": "grok", + "timeline": { + "armed": "2026-09-28T21:31:24.259Z", + "BackgroundTaskStarted(rows)": 0.256, + "wake_system_reminder": 22.097, + "reader_call": 27.134, + "reader_result": 28.911, + "probe_deadline(armed+30s)": 30.0, + "FIRST_FAILED": 30.001, + "FIRST_VERIFIED(parent_read完了)": 53.198, + "parent_read_call_row": 53.209, + "second_armed(verified+armed)": 71.99 + }, + "first_armed": { + "at": "2026-09-28T21:31:24.259Z", + "mono_ms": 62669, + "kind": "FIRST_ARMED", + "probe_deadline": 1790631114258, + "native_task": { + "id": "01a0e9ee-0022-7c81-bd78-95d75f93f3ae", + "pid": 859905 + } + }, + "first_failed": { + "at": "2026-09-28T21:31:54.260Z", + "mono_ms": 92670, + "kind": "FIRST_FAILED", + "error_code": "PARENT_PROBE_TIMEOUT", + "armed_to_failed_ms": 30001, + "deadline_to_failed_ms": 2 + }, + "first_verified": { + "at": "2026-09-28T21:32:17.457Z", + "mono_ms": 115868, + "kind": "FIRST_VERIFIED", + "runtime": "rearm_pending", + "error_code": null, + "armed_to_verified_ms": 53198, + "after_failed_ms": 23197 + }, + "final_room_health": { + "at": "2026-09-28T21:33:24.274Z", + "mono_ms": 182684, + "kind": "room_members_final", + "members": { + "state": "up", + "endpoints": [ + { + "recipient": "probe-diag-6f5dabdd", + "endpoint_id": "27476da6-332c-4aaf-8f2f-28cb7173edbc", + "state": "armed", + "beat_at": "2026-09-28T21:33:23.967Z", + "detail": "{\"endpoint_id\":\"27476da6-332c-4aaf-8f2f-28cb7173edbc\",\"state\":\"verified\",\"error_code\":null}" + } + ] + } + }, + "decision": { + "initial_probe_timeout_ms": 120000, + "binding_timeout_ms": 30000, + "owner_constant": "skill/scripts/parent-delivery.mjs:PARENT_PROBE_TIMEOUT_MS", + "basis": "正規wake→reader→parent_read完了の実測53.198秒と次のarmedまで71.99秒を含む期限とする。全OS/全harness共通で120秒に調整し、binding期限は変更しない。修正版で実機再測定する。" + }, + "limitations": [ + "wake後reader結果からparent_readまでの24秒のモデル内処理は判別できない", + "公式更新timestampは1秒精度。state遷移はfs.watchでms観測", + "room healthは終了時1回のみ。失敗期間の表示と復旧反映時刻は未確認" + ], + "report_correction": "子が表示用filterでmembersを除き空と誤認した。原rawにはup/armed/verified/error nullがありroot照合後に子が訂正した。", + "private_raw": "/private/tmp/claude-501/qa-cursor-grok/formal-audience-84de74a/raw/linux-grok-probe-diag", + "fingerprints": { + "diag-analysis.json": "f2bd44e70e5557bcaa501106478dcc49c8ffc0880eba2b429574ff5f555b94ce", + "run/events.jsonl": "1676710026820043cf95a9536bc2274a57c213ec4d034693b36288a08e48e27a", + "run/raw/updates.jsonl": "4deaedbeb3201ae86003a094e0352460f12a5d7fec818a1578206902af686c9a", + "run/raw/product-hook-events.jsonl": "b902ce17a451bbc0941610809a278deb1548272cffb5052d0b6541805c71cadc" + } +} diff --git a/rag/parent-delivery/release-candidate-checkpoint.json b/rag/parent-delivery/release-candidate-checkpoint.json index 671484e8..1d7b6a5d 100644 --- a/rag/parent-delivery/release-candidate-checkpoint.json +++ b/rag/parent-delivery/release-candidate-checkpoint.json @@ -3,7 +3,7 @@ "source_commit": "c97b431cbcb75e07a45cc17de6d688442b736af3", "runtime_digest": "ddaadee7f33139c2a7ea8d08b36d87446b3221b85c817b9a3e1afff34193186f", "package_version": "0.8.63", - "candidate_state": "held_for_windows_persistence_diagnosis", + "candidate_state": "held_for_probe_and_windows_repair_verification", "source_frozen": false, "previous_candidate_checkpoint": "rag/parent-delivery/snapshots/84de74a/release-candidate-checkpoint.json", "product_ci": { @@ -132,5 +132,19 @@ }, "repair_commit": "bce891b2cbf3a77afa7ab738e19f965e7ac9808c", "withdrawal_evidence": "rag/parent-delivery/codex-probe-rejoin-diagnosis.json", - "controller_observation_limit_ms": 1500 + "controller_observation_limit_ms": 1500, + "repairs": { + "initial_probe_timeout": { + "state": "implemented_focused_passed_live_pending", + "evidence": "rag/parent-delivery/linux-grok-probe-latency-diagnosis.json" + }, + "windows_atomic_replace": { + "state": "diagnostic_pack_focused_passed_new_git_ci_pending", + "evidence": "rag/parent-delivery/windows-rename-ci-diagnosis.json" + }, + "codex_rejoin": { + "state": "diagnostic_recovered_new_adapter_live_pending", + "evidence": "rag/parent-delivery/codex-probe-rejoin-repair-diagnosis.json" + } + } } diff --git a/rag/parent-delivery/sources/windows-posix-rename-api.metadata.json b/rag/parent-delivery/sources/windows-posix-rename-api.metadata.json new file mode 100644 index 00000000..0864e76c --- /dev/null +++ b/rag/parent-delivery/sources/windows-posix-rename-api.metadata.json @@ -0,0 +1,38 @@ +{ + "schema": "peertable.parent-windows-api-sources.v1", + "reviewed_at": "2026-09-29", + "sources": [ + { + "url": "https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-setfileinformationbyhandle", + "use": "FileRenameInfoExをSetFileInformationByHandleへ渡すAPI契約" + }, + { + "url": "https://learn.microsoft.com/en-us/windows/win32/api/winbase/ns-winbase-file_rename_info", + "use": "Flags・RootDirectory・FileNameLength・UTF16名の構造" + }, + { + "url": "https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/ns-ntifs-_file_rename_information", + "use": "POSIX_SEMANTICSは旧handle保持中の置換を認める" + }, + { + "url": "https://koffi.dev/pointers", + "use": "Bufferをvoid pointer引数へ渡す公式FFI" + }, + { + "url": "https://koffi.dev/start", + "use": "npmの公式インストールとWindows関数prototype" + }, + { + "url": "https://koffi.dev/", + "use": "Windows/Linux/macOSのprebuiltとsupported architecture" + } + ], + "native_error_preservation": { + "package": "koffi@3.3.2", + "file": "src/koffi/src/interp.cc", + "sha256": "809a3b7e401e0446fe205257721d35060b7427720240eb04aae5f7fdb6c0b073", + "verified_behavior": "Windows FFI呼出し間でTEB LastErrorValueを保存・復元するWRAPを実ソースで確認。GetLastErrorを次の同期API呼出しで取得する。" + }, + "implementation": "skill/scripts/parent-windows-files.mjs", + "runtime_evidence": "rag/parent-delivery/windows-rename-ci-diagnosis.json" +} diff --git a/rag/parent-delivery/windows-rename-ci-diagnosis.json b/rag/parent-delivery/windows-rename-ci-diagnosis.json index 0841cc09..e2ac63d5 100644 --- a/rag/parent-delivery/windows-rename-ci-diagnosis.json +++ b/rag/parent-delivery/windows-rename-ci-diagnosis.json @@ -11,11 +11,273 @@ "operation": "Bakery ticketのchoosing更新をatomicJsonで置換", "source_file": "skill/scripts/parent-platform.mjs", "observed_failure": true, - "cause": "未解明", + "cause": "置換先の読取りhandleが存命中、libuvのMoveFileExW(REPLACE_EXISTING)がERROR_ACCESS_DENIEDとなる。共有DELETE付きでも再現し、FileRenameInfoExのPOSIX_SEMANTICSで同じ保持reader下の置換が成立した。", "product_live": false, "formal_acceptance_credit": 0, "raw_log": "/private/tmp/peertable-windows-rename-c97b431/ci-failed.log", "raw_log_sha256": "dbbc60e991f86efaeaf99c3008ae9057cc3e1c4d6373a60507971db5cf8272d8", "baseline_same_runtime_ci": "https://github.com/kitepon/peertable/actions/runs/36484812856", - "diagnosis_status": "Windowsのfocused実再現とOS境界の原因照合中。rerunだけで解決としない。" + "diagnosis_status": "rootがWin32 API対照と実Node保持readerの原記録を照合。Windows OS adapterを修理し、診断packでfocused31件合格。新Git候補の3 OS CIと正式実機受入は未完了。", + "limitations": [ + "元CIでreader handleを直接追跡しておらず、元CIのhandle保持processの特定は未確認", + "診断packは未commit修理の原bytes。正式受入credit0", + "x64 Windows NTFSの実測。Windows ARM64/ia32、別filesystemの実機試験は未実施。非対応APIのerrorを返しfallbackしない", + "別Node fixtureのnew_path_body_okは末尾・長さ・Unicodeの部分確認。本文全量deepEqualは関連focused testで実施", + "Windows試験時の環境と受信pack hashは原fileなしの端末転記。今回の環境/自己process0/削除後照会は別にraw保存。正式sourceの合格へ流用しない。" + ], + "api_probe": [ + { + "case": "A_no_reader__MoveFileExW_replace", + "move_error": 0 + }, + { + "case": "B_reader_share_RWD (libuv open share)__MoveFileExW_replace", + "reader_share": 7, + "move_error": 5, + "reader_handle": 2296 + }, + { + "case": "C_reader_share_RW_no_delete__MoveFileExW_replace", + "reader_share": 3, + "move_error": 5, + "reader_handle": 2296 + }, + { + "case": "D_reader_share_R_only__MoveFileExW_replace", + "reader_share": 1, + "move_error": 5, + "reader_handle": 2296 + }, + { + "case": "E_reader_share_RWD__POSIX_rename(FileRenameInfoEx)", + "posix_error": 0, + "reader_handle": 2296 + }, + { + "case": "F_node_fs.openSync_reader__replace", + "holder_pid": 47672, + "error": 5 + }, + { + "case": "F_node_fs.openSync_reader__posix", + "holder_pid": 10604, + "error": 0 + } + ], + "repair": { + "owner": "skill/scripts/parent-platform.mjs / parent-windows-files.mjs", + "api": "SetFileInformationByHandle(FileRenameInfoEx,REPLACE_IF_EXISTS|POSIX_SEMANTICS)", + "dependency": "koffi@3.3.2", + "pack_sha256": "a37a3c10f23c83a1141ba78d99a699f954e83e246f2c5ed78eaa45b48c11e561", + "focused": { + "passed": 31, + "failed": 0 + }, + "private_raw": "/private/tmp/claude-501/-Users-kite--codex-worktrees-parent-acceptance-runner-peertable/6578ec5b-7de6-4c99-87a4-7a9b9114360b/scratchpad/atomic-repair", + "separate_node_reader": { + "fixture-normal": { + "mode": "normal", + "dest_len": 81, + "import_ms": 4.6, + "first_replace_ms": 7.9, + "steady_19_median_ms": 0.62, + "steady_19_max_ms": 0.72, + "steady_19_min_ms": 0.52, + "replacements": 20, + "errors": [], + "old_fd_kept_old_body": true, + "old_fd_has_new": false, + "new_path_generation": 20, + "new_path_body_ok": true, + "tmp_leftovers": [], + "holder_stderr": "" + }, + "fixture-longpath": { + "mode": "longpath", + "dest_len": 370, + "import_ms": 4.1, + "first_replace_ms": 7.4, + "steady_19_median_ms": 0.81, + "steady_19_max_ms": 1.23, + "steady_19_min_ms": 0.75, + "replacements": 20, + "errors": [], + "old_fd_kept_old_body": true, + "old_fd_has_new": false, + "new_path_generation": 20, + "new_path_body_ok": true, + "tmp_leftovers": [], + "holder_stderr": "" + } + }, + "root_pack_file_hashes": { + "package.json": "dafd96cd6e84ded12976d51553c255921e49a3809f159530d32918626bc7822b", + "skill/scripts/parent-platform.mjs": "45b06cf65ee6413b795fa0f00225250fc5f9b42164268b31a6b39fe4440c6d34", + "skill/scripts/parent-windows-files.mjs": "ad9b8d741a950ff673598c6bfaa257503883effaba23fcdbfcf368ca74a16be0", + "skill/scripts/parent-delivery.mjs": "390ca733e9dd1f6a36ecda01166b176821fb03962a9b65f2a8483e05199f8f9c", + "skill/scripts/parent-runtime.mjs": "decc27240f144753b371220dd14abca170b4d166f05abc216ee7a60246b815c9", + "skill/scripts/parent-runtime-probe.test.mjs": "b98a8f8fd3eb53c903a2e5cd75f79c26820406467cc7b828c026ff0d74ea36c7", + "skill/scripts/parent-delivery.test.mjs": "25e3ca5a50e3ebeea05e746b1755c7875063724641beb792f5cc43ebb1baac36", + "skill/scripts/parent-platform-atomic.test.mjs": "c39490daaa34a28b9404416aa12b44cb58e3700e2ec8c0d37969ccc869e0b1f4" + }, + "test_fixture_changed_after_pack": "Mac MAXPATHLEN以内で260文字超のpathを作るため段数と部品長のみ変更。製品moduleは不変。", + "remote_pack_hash_evidence": "Windows受信packのhashは端末転記のみ。削除済みでraw原file・再hashは無い。保持するroot原packの全SHAとlocal 8fileはrootが照合した。", + "cleanup": { + "schema": "peertable.atomic-repair-windows-cleanup-environment.v1", + "queried_at": "2026-09-28T21:50:13.3702216Z", + "query_kind": "read-only(試験は再実行していない)", + "corrections": { + "source_pack_binding_files": "実際に照合したのは8file(報告の10fileは誤り)。packに入らない package-lock.json と scripts/run-product-ci.mjs は照合対象外。", + "fixture_new_path_body_ok": "別Node fixtureのnew_path_body_okは endsWith(😀)・length>100・unicodeフィールド一致だけの判定。「全量一致」の主張はこのfixtureには付けない。全量deepEqualの確認は parent-platform-atomic.test に限る。", + "atomic_test_file_now": "pack作成時はroot作業ツリーと8file全て一致(16桁 c39490daaa34a28b)。現在のroot作業ツリーの parent-platform-atomic.test.mjs は別SHA(root側の長path fixture修正)。試験に使ったのはpack原bytes。" + }, + "test_time_environment": { + "source": "試験実行時のstdout(端末表示)。fileには保存していない=原file無し。下記の値は端末出力の転記。", + "recorded_in_file": false, + "node": "v24.20.0", + "os": "Microsoft Windows NT 10.0.26200.0", + "arch": "X64", + "koffi": "3.3.2", + "npm_install_exit": 0, + "npm_install_ms": 2774, + "node_test_exit": 0, + "node_test_ms": 7255 + }, + "environment_now_readonly": { + "node": "v24.20.0", + "os_caption": "Microsoft Windows 11 Pro", + "os_version": "10.0.26200", + "os_build": "26200", + "architecture": "X64", + "process_architecture": "X64", + "host": "FOX", + "user": "kite_" + }, + "received_pack": { + "full_sha256_seen_on_fox_at_transfer": "a37a3c10f23c83a1141ba78d99a699f954e83e246f2c5ed78eaa45b48c11e561", + "source_of_that_value": "転送直後のfox上 Get-FileHash のstdout(端末表示)。fileには保存していない=原file無し。", + "fox_copy_now": "削除済みのため再ハッシュ不可", + "retained_original_pack_sha256_now(Mac,/private/tmp/peertable-windows-atomic-repair)": "a37a3c10f23c83a1141ba78d99a699f954e83e246f2c5ed78eaa45b48c11e561", + "equals_transfer_value": true + }, + "source_pack_binding_full_sha256_8files": [ + { + "file": "package.json", + "root_now": "dafd96cd6e84ded12976d51553c255921e49a3809f159530d32918626bc7822b", + "pack": "dafd96cd6e84ded12976d51553c255921e49a3809f159530d32918626bc7822b", + "status": "SAME" + }, + { + "file": "skill/scripts/parent-delivery.mjs", + "root_now": "390ca733e9dd1f6a36ecda01166b176821fb03962a9b65f2a8483e05199f8f9c", + "pack": "390ca733e9dd1f6a36ecda01166b176821fb03962a9b65f2a8483e05199f8f9c", + "status": "SAME" + }, + { + "file": "skill/scripts/parent-delivery.test.mjs", + "root_now": "25e3ca5a50e3ebeea05e746b1755c7875063724641beb792f5cc43ebb1baac36", + "pack": "25e3ca5a50e3ebeea05e746b1755c7875063724641beb792f5cc43ebb1baac36", + "status": "SAME" + }, + { + "file": "skill/scripts/parent-platform.mjs", + "root_now": "45b06cf65ee6413b795fa0f00225250fc5f9b42164268b31a6b39fe4440c6d34", + "pack": "45b06cf65ee6413b795fa0f00225250fc5f9b42164268b31a6b39fe4440c6d34", + "status": "SAME" + }, + { + "file": "skill/scripts/parent-runtime.mjs", + "root_now": "decc27240f144753b371220dd14abca170b4d166f05abc216ee7a60246b815c9", + "pack": "decc27240f144753b371220dd14abca170b4d166f05abc216ee7a60246b815c9", + "status": "SAME" + }, + { + "file": "skill/scripts/parent-runtime-probe.test.mjs", + "root_now": "b98a8f8fd3eb53c903a2e5cd75f79c26820406467cc7b828c026ff0d74ea36c7", + "pack": "b98a8f8fd3eb53c903a2e5cd75f79c26820406467cc7b828c026ff0d74ea36c7", + "status": "SAME" + }, + { + "file": "skill/scripts/parent-platform-atomic.test.mjs", + "root_now": "ae21f7898c62e7b47535f55154c03797b596d0e51798f7d9e2f6df41ff2125ff", + "pack": "c39490daaa34a28b9404416aa12b44cb58e3700e2ec8c0d37969ccc869e0b1f4", + "status": "DIFF" + }, + { + "file": "skill/scripts/parent-windows-files.mjs", + "root_now": "ad9b8d741a950ff673598c6bfaa257503883effaba23fcdbfcf368ca74a16be0", + "pack": "ad9b8d741a950ff673598c6bfaa257503883effaba23fcdbfcf368ca74a16be0", + "status": "SAME" + } + ], + "cleanup_readonly": { + "base_path_recorded": "C:\\Users\\kite_\\AppData\\Local\\Temp\\peertable-atomic-1790631828", + "base_exists": false, + "prefix_exists": false, + "fixtures_exist": false, + "pack_copy_exists": false, + "self_fixture_processes": 0 + }, + "fox_out_files_sha256": [ + { + "name": "base-path.txt", + "bytes": 63, + "sha256": "5ed7891eada5788d796b68085cf8e8985eed971232372a1c5144b145a7758e05" + }, + { + "name": "claim-race-x40.json.txt", + "bytes": 122, + "sha256": "24c32d3f6388056be3672452c156d626929129e8c2f4b40507b4bcd9ad56dfab" + }, + { + "name": "fixture-control-before.json.txt", + "bytes": 9504, + "sha256": "0e7b80ab45dd60473544a1bddb106091b2b5cffe14426fa676b33ce8efaa398b" + }, + { + "name": "fixture-longpath.json.txt", + "bytes": 318, + "sha256": "a067d7f16029c2d883fc078f32d577057cc277962a50ea36304672d74e242d29" + }, + { + "name": "fixture-normal.json.txt", + "bytes": 315, + "sha256": "017eccb54e2c84b4581c9b7ba4461464c3963be0f14dc255b4fa1d7be34bac64" + }, + { + "name": "node-test.stderr.txt", + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "name": "node-test.stdout.txt", + "bytes": 3321, + "sha256": "4b76ccae584595726559858554a89b2bcd0a37e11021478f799b8d39647122b2" + }, + { + "name": "npm-install.stderr.txt", + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "name": "npm-install.stdout.txt", + "bytes": 27, + "sha256": "686a3a193dcbf6186618eff5e50dae3074b29c324b32be2d34470e5a8d9f1fc6" + } + ] + }, + "cleanup_environment_raw_sha256": "1b368634d21a35fac3108a59a4cf17d44512467380eec5487d99ab92e0dba252", + "remote_readonly_environment_raw_sha256": "201c5eb8a017497f489e1681f25972560be8b3d0c6317bcfcae09e252d2344e3" + }, + "raw_fingerprints": { + "ci-win-eperm/move-replace-probe.ps1": "cd2385a3ecef71be6308bf8ba4ffc8edc05c2f347d7ba38a8c91ee66c7a0e7b8", + "ci-win-eperm/move-replace-probe-results.jsonl": "2b59e6924b9a50299eacb8188e8a67e67164d2c3c6dd1091b4d26fe23a1ea29f", + "atomic-repair/source-pack-binding.txt": "64fbfa0b7ee464d82d391d2cf943eb04d77f07015e22fd575aec24b26b70ad79", + "atomic-repair/atomic-fixture.mjs": "132c4dc0f872a12b1d0115db42c69b7ab974d0eb7c3cffce72e0d30692cc2c4d", + "atomic-repair/raw/node-test.stdout.txt": "4b76ccae584595726559858554a89b2bcd0a37e11021478f799b8d39647122b2", + "atomic-repair/raw/fixture-normal.json.txt": "017eccb54e2c84b4581c9b7ba4461464c3963be0f14dc255b4fa1d7be34bac64", + "atomic-repair/raw/fixture-longpath.json.txt": "a067d7f16029c2d883fc078f32d577057cc277962a50ea36304672d74e242d29", + "atomic-repair/raw/fixture-control-before.json.txt": "0e7b80ab45dd60473544a1bddb106091b2b5cffe14426fa676b33ce8efaa398b", + "atomic-repair/raw/claim-race-x40.json.txt": "24c32d3f6388056be3672452c156d626929129e8c2f4b40507b4bcd9ad56dfab" + } } diff --git a/scripts/run-product-ci.mjs b/scripts/run-product-ci.mjs index 1afe84f0..2ffbf865 100644 --- a/scripts/run-product-ci.mjs +++ b/scripts/run-product-ci.mjs @@ -30,7 +30,7 @@ for (const directory of ['room', 'skill/scripts', 'scripts']) { run(process.execPath, ['--test', 'skill/scripts/runtime-contract.test.mjs']) run(process.execPath, ['experiments/aiterm-unified-delivery-repro.mjs']) run(process.execPath, ['--test', 'scripts/ci-contract.test.mjs', 'scripts/docs-contract.test.mjs']) -run(process.execPath, ['--test', 'skill/scripts/parent-delivery.test.mjs', 'skill/scripts/parent-source-runtime.test.mjs', 'skill/scripts/parent-process.test.mjs', 'skill/scripts/parent-platform-unlinked-executable.test.mjs', 'room/parent-client.test.mjs', 'scripts/parent-delivery-acceptance.test.mjs']) +run(process.execPath, ['--test', 'skill/scripts/parent-delivery.test.mjs', 'skill/scripts/parent-runtime-probe.test.mjs', 'skill/scripts/parent-source-runtime.test.mjs', 'skill/scripts/parent-process.test.mjs', 'skill/scripts/parent-platform-atomic.test.mjs', 'skill/scripts/parent-platform-unlinked-executable.test.mjs', 'room/parent-client.test.mjs', 'scripts/parent-delivery-acceptance.test.mjs']) run(process.execPath, ['experiments/delivery-receipt-repro.mjs']) run(process.execPath, ['experiments/http-utf8-body-repro.mjs']) run(process.execPath, ['experiments/windows-seat-mux-repro.mjs']) diff --git a/skill/scripts/parent-delivery.mjs b/skill/scripts/parent-delivery.mjs index cb539840..f6be5179 100644 --- a/skill/scripts/parent-delivery.mjs +++ b/skill/scripts/parent-delivery.mjs @@ -12,10 +12,12 @@ const canonical = value => value === null || typeof value !== 'object' ? JSON.st : `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}` export const digest = value => createHash('sha256').update(typeof value === 'string' ? value : canonical(value)).digest('hex') export const PAGE_CHARS = 12000 +// 正規背景通知の受取、reader、parent_readまでを含む初回確認の期限。 +export const PARENT_PROBE_TIMEOUT_MS = 120000 export const eventKinds = ['parent_dm', 'parent_room_update', 'parent_lattice_error', 'parent_lattice_update', 'parent_table_stalled', 'parent_watch_snapshot', 'watch_error', 'parent_probe'] export function armParentState(state) { state.runtime = 'armed' - if (state.state !== 'verified' && state.probe_deadline == null) state.probe_deadline = Date.now() + 30000 + if (state.state !== 'verified' && state.probe_deadline == null) state.probe_deadline = Date.now() + PARENT_PROBE_TIMEOUT_MS } // 明示再登録で新しいprobeが届いた場合だけ、既知の失敗probeを解決済みにする。 export const failedParentRecords = state => state.records.filter(record => record.state === 'failed' && !(record.event.type === 'parent_probe' && record.resolved_by)) diff --git a/skill/scripts/parent-delivery.test.mjs b/skill/scripts/parent-delivery.test.mjs index 2edf332f..aa2eed98 100644 --- a/skill/scripts/parent-delivery.test.mjs +++ b/skill/scripts/parent-delivery.test.mjs @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os' import { spawn } from 'node:child_process' import { Readable } from 'node:stream' import { randomUUID } from 'node:crypto' -import { ParentSpool, PAGE_CHARS, renderDelivery, digest, armParentState } from './parent-delivery.mjs' +import { ParentSpool, PAGE_CHARS, renderDelivery, digest, armParentState, PARENT_PROBE_TIMEOUT_MS } from './parent-delivery.mjs' import { processIdentity, processHarness, harnessProcess, readHookEvent, atomicJson } from './parent-platform.mjs' import { queueCodex, codexHook, checkCodexReceiver } from './parent-receivers/codex.mjs' import { mergeOwnedHooks, tomlHeaderKeys, replaceOwnedToml, ownedTomlBlock, hookEntries, ownsParentConnection, grokMcpBlock } from './parent-connect.mjs' @@ -212,7 +212,7 @@ test('probe期限は受信準備から始め、確認済みprobeだけが自身 assert.equal(spool.read().probe_deadline, undefined) const before = Date.now() spool.transact(armParentState) - assert.ok(spool.read().probe_deadline >= before + 30000) + assert.ok(spool.read().probe_deadline >= before + PARENT_PROBE_TIMEOUT_MS) const probe = spool.saveEvent({ type: 'parent_probe', event_id: 'probe', body: '疎通確認' }) const claimed = spool.claim('grok_background', probe.delivery_id) spool.update({ state: 'failed', runtime: 'failed', error_code: 'PARENT_PROBE_TIMEOUT' }) diff --git a/skill/scripts/parent-platform-atomic.test.mjs b/skill/scripts/parent-platform-atomic.test.mjs new file mode 100644 index 00000000..f1ce94b2 --- /dev/null +++ b/skill/scripts/parent-platform-atomic.test.mjs @@ -0,0 +1,35 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, openSync, closeSync, readFileSync, readdirSync, mkdirSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { atomicJson, readJson } from './parent-platform.mjs' + +test('読取り中の旧fileを保ち、Unicodeと長いpathのJSONをatomicに置換する', t => { + const root = mkdtempSync(join(tmpdir(), 'peertable atomic 日本語 ')) + t.after(() => rmSync(root, { recursive: true, force: true })) + const dir = join(root, ...Array.from({ length: 7 }, (_, i) => `${i}-${'名前'.repeat(16)}`)) + const file = join(dir, '親の状態😀.json'), original = { revision: 0, body: '旧本文\r\n<&>😀' } + atomicJson(file, original) + const reader = openSync(file, 'r') + try { + for (let revision = 1; revision <= 20; revision++) { + const next = { revision, body: '新本文\r\n<&>😀'.repeat(revision) } + atomicJson(file, next) + assert.deepEqual(readJson(file), next) + } + assert.deepEqual(JSON.parse(readFileSync(reader, 'utf8')), original) + } finally { closeSync(reader) } + assert.deepEqual(readdirSync(dir), ['親の状態😀.json']) +}) + +test('OSの置換失敗は原errorを返し、別経路で成功にしない', t => { + const root = mkdtempSync(join(tmpdir(), 'peertable atomic error ')) + t.after(() => rmSync(root, { recursive: true, force: true })) + const destination = join(root, 'directory.json') + mkdirSync(destination) + assert.throws(() => atomicJson(destination, { body: '保存できない本文' }), error => + process.platform === 'win32' ? error.code === 'PARENT_ATOMIC_REPLACE_FAILED' && Number.isInteger(error.win32_error) + : error.code === 'EISDIR' || error.code === 'ENOTDIR') + assert.equal(readdirSync(root).filter(name => name.endsWith('.tmp')).length, 1) +}) diff --git a/skill/scripts/parent-platform.mjs b/skill/scripts/parent-platform.mjs index 6948bac6..9d0085a5 100644 --- a/skill/scripts/parent-platform.mjs +++ b/skill/scripts/parent-platform.mjs @@ -5,6 +5,7 @@ import { dirname, join, basename } from 'node:path' import { createRequire } from 'node:module' import { homedir } from 'node:os' import { randomUUID } from 'node:crypto' +import { replaceWindowsFile } from './parent-windows-files.mjs' export const parentHome = () => join(homedir(), '.peertable', 'parent-receivers') export const failure = (code, detail = code) => Object.assign(new Error(detail), { code }) @@ -20,7 +21,8 @@ export function atomicJson(file, value) { mkdirSync(dirname(file), { recursive: true, mode: 0o700 }) const temp = `${file}.${process.pid}.${randomUUID()}.tmp` writeFileSync(temp, `${JSON.stringify(value)}\n`, { mode: 0o600, flag: 'wx' }) - renameSync(temp, file) + if (process.platform === 'win32') replaceWindowsFile(temp, file) + else renameSync(temp, file) } export const psQuote = value => `'${String(value).replaceAll("'", "''")}'` export const posixQuote = value => `'${String(value).replaceAll("'", "'\\''")}'` diff --git a/skill/scripts/parent-runtime-probe.test.mjs b/skill/scripts/parent-runtime-probe.test.mjs index d3be85a5..36db9e8a 100644 --- a/skill/scripts/parent-runtime-probe.test.mjs +++ b/skill/scripts/parent-runtime-probe.test.mjs @@ -6,7 +6,7 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { atomicJson, processIdentity } from './parent-platform.mjs' import { joinEndpoint, stopEndpoint, projectEndpoints } from './parent-runtime.mjs' -import { failedParentRecords } from './parent-delivery.mjs' +import { failedParentRecords, PARENT_PROBE_TIMEOUT_MS } from './parent-delivery.mjs' test('初回Codex joinはwatcher起動前にprobe期限を開始し、背景登録待ちのCursorは開始しない', async t => { const project = realpathSync(mkdtempSync(join(tmpdir(), 'peertable-join-probe-'))) @@ -50,7 +50,7 @@ test('初回Codex joinはwatcher起動前にprobe期限を開始し、背景登 assert.equal(state.state, 'receiving') if (harness === 'codex') { assert.equal(state.runtime, 'armed') - assert.ok(state.probe_deadline >= before + 30000 && state.probe_deadline <= after + 30000) + assert.ok(state.probe_deadline >= before + PARENT_PROBE_TIMEOUT_MS && state.probe_deadline <= after + PARENT_PROBE_TIMEOUT_MS) } else { assert.equal(state.runtime, 'rearm_pending') assert.equal(state.probe_deadline, undefined) diff --git a/skill/scripts/parent-runtime.mjs b/skill/scripts/parent-runtime.mjs index 314f867b..56b12894 100644 --- a/skill/scripts/parent-runtime.mjs +++ b/skill/scripts/parent-runtime.mjs @@ -5,7 +5,7 @@ import { execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' import { setTimeout as delay } from 'node:timers/promises' import { randomUUID } from 'node:crypto' -import { ParentSpool, armParentState, failedParentRecords } from './parent-delivery.mjs' +import { ParentSpool, armParentState, failedParentRecords, PARENT_PROBE_TIMEOUT_MS } from './parent-delivery.mjs' import { atomicJson, readJson, processIdentity, sameProcess, failure, posixQuote, psQuote } from './parent-platform.mjs' import { registerEndpoint, endpointsFor, forgetEndpoint } from './parent-caller.mjs' import { ownsParentConnection } from './parent-connect.mjs' @@ -129,7 +129,7 @@ export async function joinEndpoint(projectArg, name, caller, display = {}) { const saved = spool.read() if (!Number.isSafeInteger(saved.cursor)) throw failure('PARENT_CURSOR_MISSING') const runtime = caller.harness === 'codex' ? 'armed' : saved.waiter && sameProcess(saved.waiter.owner) ? saved.runtime : 'rearm_pending' - spool.update({ caller, runtime, state: saved.state === 'verified' ? 'verified' : 'receiving', probe_deadline: runtime === 'armed' && saved.state !== 'verified' ? Date.now() + 30000 : null, error_code: null }) + spool.update({ caller, runtime, state: saved.state === 'verified' ? 'verified' : 'receiving', probe_deadline: runtime === 'armed' && saved.state !== 'verified' ? Date.now() + PARENT_PROBE_TIMEOUT_MS : null, error_code: null }) } else { for (const old of projectEndpoints(project).filter(item => item.read().name === name && item.read().runtime !== 'stopped')) await stopEndpoint(old) const summary = await api.request('summary') diff --git a/skill/scripts/parent-windows-files.mjs b/skill/scripts/parent-windows-files.mjs new file mode 100644 index 00000000..5dc113bb --- /dev/null +++ b/skill/scripts/parent-windows-files.mjs @@ -0,0 +1,43 @@ +// Windows標準APIで、読取り中の旧fileを保ったまま名前をatomicに置換する。 +import { createRequire } from 'node:module' +import { resolve, toNamespacedPath } from 'node:path' + +let api +function windowsApi() { + if (api) return api + const koffi = createRequire(import.meta.url)('koffi') + const kernel = koffi.load('kernel32.dll') + api = { + pointerBytes: koffi.sizeof('void *'), + open: kernel.func('intptr_t __stdcall CreateFileW(const char16_t *name, uint32_t access, uint32_t share, void *security, uint32_t disposition, uint32_t flags, intptr_t templateFile)'), + rename: kernel.func('int32_t __stdcall SetFileInformationByHandle(intptr_t file, int32_t infoClass, const void *info, uint32_t size)'), + close: kernel.func('int32_t __stdcall CloseHandle(intptr_t file)'), + error: kernel.func('uint32_t __stdcall GetLastError()'), + } + return api +} +const windowsFailure = (operation, error, source, destination) => Object.assign( + new Error(`${operation}: Windows error ${error}: ${source} → ${destination}`), + { code: 'PARENT_ATOMIC_REPLACE_FAILED', operation, win32_error: error, path: source, dest: destination }, +) +export function replaceWindowsFile(source, destination) { + const native = windowsApi() + // DELETE権限、共有READ|WRITE|DELETE、OPEN_EXISTING。既に書き終えた自分のtempを開く。 + const handle = native.open(toNamespacedPath(resolve(source)), 0x10000, 7, null, 3, 0, 0) + if (handle === -1 || handle === -1n) throw windowsFailure('CreateFileW', native.error(), source, destination) + let renameError, closeError + try { + const name = Buffer.from(toNamespacedPath(resolve(destination)), 'utf16le') + const lengthOffset = native.pointerBytes * 2, nameOffset = lengthOffset + 4 + const info = Buffer.alloc(nameOffset + name.length + 2) + // FILE_RENAME_INFO: union Flags、pointer境界へ整列したRootDirectory、長さ、UTF-16名。 + info.writeUInt32LE(3, 0) // REPLACE_IF_EXISTS | POSIX_SEMANTICS + info.writeUInt32LE(name.length, lengthOffset) + name.copy(info, nameOffset) + if (!native.rename(handle, 22, info, info.length)) renameError = native.error() // FileRenameInfoEx + } finally { + if (!native.close(handle)) closeError = native.error() + } + if (renameError !== undefined) throw windowsFailure('SetFileInformationByHandle', renameError, source, destination) + if (closeError !== undefined) throw windowsFailure('CloseHandle', closeError, source, destination) +} From edd19ca020c51bd7311546e5ead772e8d53fac96 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:56:10 +0900 Subject: [PATCH 66/72] =?UTF-8?q?=E4=BF=AE=E7=90=86=E7=89=88=E3=81=AE3=20O?= =?UTF-8?q?S=20CI=E3=81=A8=E8=A8=BA=E6=96=AD=E9=85=8D=E5=B8=83=E7=89=A9?= =?UTF-8?q?=E3=81=AE=E7=8F=BE=E5=9C=A8=E5=9C=B0=E3=82=92=E8=A8=98=E9=8C=B2?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plan_parent-native-delivery.md | 4 +- rag/parent-delivery/cli-lifecycle-repairs.md | 2 +- .../release-candidate-checkpoint.json | 47 +++--- .../c97b431/release-candidate-checkpoint.json | 150 ++++++++++++++++++ .../windows-rename-ci-diagnosis.json | 11 +- 5 files changed, 191 insertions(+), 23 deletions(-) create mode 100644 rag/parent-delivery/snapshots/c97b431/release-candidate-checkpoint.json diff --git a/docs/plan_parent-native-delivery.md b/docs/plan_parent-native-delivery.md index abb6c703..3e1b62fe 100644 --- a/docs/plan_parent-native-delivery.md +++ b/docs/plan_parent-native-delivery.md @@ -398,8 +398,8 @@ controllerはCursorの公式postToolUseが確定した入力全体を照合す 正式probe期限試験のcontrollerは、製品の実30秒期限と失敗の観測時刻を分ける。全OS・全harnessに共通の観測上限は既存focused timer試験と同じ1500msとし、失敗の実lagを原値で保存する。期限前、時刻欠落、health不一致、上限超過をtyped failureにし、製品deadlineを延ばして合格へ丸めない。Windowsの所有child停止APIと各harnessの故障発火が実測されていない行は、引き続き未合格とする。 -再登録修理後のWindows CIでは、Bakery ticketのatomic置換で実`EPERM`が発生した。[原記録とAPI対照](../rag/parent-delivery/windows-rename-ci-diagnosis.json)から読取りhandle保持中の通常renameを再現し、Windows標準のPOSIX互換APIで修理した。診断packのfocused試験は合格したが、新候補の3 OS CIは未完了。Linux Grokの初回probeは正常な本文回収時間を測って期限を調整した。Windows Cursorのlock timeoutは別に原因照合が残る。正式配送試験と公開は、新候補でその境界を確認してから進める。 +再登録修理後のWindows CIでは、Bakery ticketのatomic置換で実`EPERM`が発生した。[原記録とAPI対照](../rag/parent-delivery/windows-rename-ci-diagnosis.json)から読取りhandle保持中の通常renameを再現し、Windows標準のPOSIX互換APIで修理した。診断packのfocused試験と修理版の3 OS CIは合格した。Linux Grokの初回probeは正常な本文回収時間を測って期限を調整した。Windows Cursorのlock timeoutは別に原因照合が残る。正式配送試験と公開は、新候補でその境界を確認してから進める。 [Linux Grokの正規通知の実診断](../rag/parent-delivery/linux-grok-probe-latency-diagnosis.json)では、本文回収まで53.198秒、次の受信登録まで71.99秒かかった。初回probe期限を共通120秒へ調整し、束縛期限を維持する。期限失敗を成功へ数えず、新候補で正常な確認と実期限失敗の両方を再測定する。 -[Windowsの保持reader対照](../rag/parent-delivery/windows-rename-ci-diagnosis.json)で、libuvの通常置換APIが共有DELETE付きでもACCESS_DENIEDとなり、POSIX互換置換APIでは同じ保持reader下で成立することを確認した。Windowsのatomic更新だけを標準APIへ適合し、排他・spool・receiptの仕組みは全OS共通に保つ。診断packのfocused31件は合格したが、新Git候補の3 OS CIと正式実機は未完了。 +[Windowsの保持reader対照](../rag/parent-delivery/windows-rename-ci-diagnosis.json)で、libuvの通常置換APIが共有DELETE付きでもACCESS_DENIEDとなり、POSIX互換置換APIでは同じ保持reader下で成立することを確認した。Windowsのatomic更新だけを標準APIへ適合し、排他・spool・receiptの仕組みは全OS共通に保つ。診断packのfocused31件と新Git候補の3 OS CIは合格した。新候補の正常Grok probe・Windows Cursor排他待ちの診断と正式実機受入は未完了。 diff --git a/rag/parent-delivery/cli-lifecycle-repairs.md b/rag/parent-delivery/cli-lifecycle-repairs.md index bef04914..6ea97db0 100644 --- a/rag/parent-delivery/cli-lifecycle-repairs.md +++ b/rag/parent-delivery/cli-lifecycle-repairs.md @@ -72,4 +72,4 @@ Cursorのidle確認には[公式stop仕様](https://cursor.com/docs/hooks#stop) ## Windowsの保持readerとatomic置換 -[実APIと製品packの対照](windows-rename-ci-diagnosis.json)では、通常renameが保持reader下で失敗し、POSIX互換renameで旧fd保持と新pathの置換が成立した。製品のOS adapterはWindows標準APIを直接呼び、PowerShellの起動を更新ごとに挟まない。API非対応や権限失敗は原Windows errorを保持して返す。新候補の3 OS CIと正式実機は未完了。 +[実APIと製品packの対照](windows-rename-ci-diagnosis.json)では、通常renameが保持reader下で失敗し、POSIX互換renameで旧fd保持と新pathの置換が成立した。製品のOS adapterはWindows標準APIを直接呼び、PowerShellの起動を更新ごとに挟まない。API非対応や権限失敗は原Windows errorを保持して返す。修理版の3 OS CIは合格した。Windows Cursor排他待ちの原因照合と正式実機は未完了。 diff --git a/rag/parent-delivery/release-candidate-checkpoint.json b/rag/parent-delivery/release-candidate-checkpoint.json index 1d7b6a5d..1418f1b8 100644 --- a/rag/parent-delivery/release-candidate-checkpoint.json +++ b/rag/parent-delivery/release-candidate-checkpoint.json @@ -1,22 +1,22 @@ { "schema": "peertable.parent-release-checkpoint.v1", - "source_commit": "c97b431cbcb75e07a45cc17de6d688442b736af3", - "runtime_digest": "ddaadee7f33139c2a7ea8d08b36d87446b3221b85c817b9a3e1afff34193186f", + "source_commit": "980cbebc1acab7461daa064abec8814b410f0137", + "runtime_digest": "19cceff45965f64a0733b9bc8f207fca58a2295b8d0c54ad1e0deb72f573c5b1", "package_version": "0.8.63", - "candidate_state": "held_for_probe_and_windows_repair_verification", + "candidate_state": "diagnostic_candidate_windows_cursor_lock_pending", "source_frozen": false, - "previous_candidate_checkpoint": "rag/parent-delivery/snapshots/84de74a/release-candidate-checkpoint.json", + "previous_candidate_checkpoint": "rag/parent-delivery/snapshots/c97b431/release-candidate-checkpoint.json", "product_ci": { - "status": "failure", - "source_commit": "c97b431cbcb75e07a45cc17de6d688442b736af3", - "head_commit": "c97b431cbcb75e07a45cc17de6d688442b736af3", - "url": "https://github.com/kitepon/peertable/actions/runs/36485234948", + "status": "success", + "source_commit": "980cbebc1acab7461daa064abec8814b410f0137", + "head_commit": "980cbebc1acab7461daa064abec8814b410f0137", + "url": "https://github.com/kitepon/peertable/actions/runs/36488941014", "native_os": { "darwin": "passed", "linux": "passed", - "win32": "failed" + "win32": "passed" }, - "failure_evidence": "rag/parent-delivery/windows-rename-ci-diagnosis.json" + "previous_failure_evidence": "rag/parent-delivery/windows-rename-ci-diagnosis.json" }, "focused_verification": { "darwin_process_exit": { @@ -66,6 +66,17 @@ "passed": 23, "failed": 0, "live_acceptance_credit": 0 + }, + "windows_atomic_replace": { + "passed": 31, + "failed": 0, + "formal_acceptance_credit": 0, + "diagnostic_pack_only": true + }, + "native_deadline_and_rejoin_controller": { + "passed": 25, + "failed": 0, + "live_acceptance_credit": 0 } }, "documentation_tests": { @@ -74,18 +85,18 @@ "skipped": 0 }, "package": { - "status": "fixture_installed_acceptance_held", - "sha256": "823336e4397dabc77001d03dc57089822e1b3e48fbd2b4248623323007796329", - "files": 133, - "dry_run_files": 133, - "installed_runtime_digest": "ddaadee7f33139c2a7ea8d08b36d87446b3221b85c817b9a3e1afff34193186f", + "status": "fixture_installed_diagnostics_only", + "sha256": "9fc56bbce1f49015e49de9ca4110303ca83f7acd8b3e6b66e4b3d9f57b71bdad", + "files": 135, + "dry_run_files": 135, + "installed_runtime_digest": "19cceff45965f64a0733b9bc8f207fca58a2295b8d0c54ad1e0deb72f573c5b1", "registry_install": false, "skill_placement": { "status": "required", "reason": "専用prefixのfixture導入はignore-scriptsを使い、既存のglobalスキル配置を変更していない。registryのglobal installは公開後に行う。" }, "parent_connections": { - "codex": "registered_fixed_candidate_for_diagnostic", + "codex": "previous_candidate", "claude": "previous_candidate", "cursor": "previous_candidate", "grok": "previous_candidate" @@ -135,11 +146,11 @@ "controller_observation_limit_ms": 1500, "repairs": { "initial_probe_timeout": { - "state": "implemented_focused_passed_live_pending", + "state": "focused_and_three_os_ci_passed_live_pending", "evidence": "rag/parent-delivery/linux-grok-probe-latency-diagnosis.json" }, "windows_atomic_replace": { - "state": "diagnostic_pack_focused_passed_new_git_ci_pending", + "state": "diagnostic_focused_and_three_os_ci_passed_native_diagnosis_pending", "evidence": "rag/parent-delivery/windows-rename-ci-diagnosis.json" }, "codex_rejoin": { diff --git a/rag/parent-delivery/snapshots/c97b431/release-candidate-checkpoint.json b/rag/parent-delivery/snapshots/c97b431/release-candidate-checkpoint.json new file mode 100644 index 00000000..1d7b6a5d --- /dev/null +++ b/rag/parent-delivery/snapshots/c97b431/release-candidate-checkpoint.json @@ -0,0 +1,150 @@ +{ + "schema": "peertable.parent-release-checkpoint.v1", + "source_commit": "c97b431cbcb75e07a45cc17de6d688442b736af3", + "runtime_digest": "ddaadee7f33139c2a7ea8d08b36d87446b3221b85c817b9a3e1afff34193186f", + "package_version": "0.8.63", + "candidate_state": "held_for_probe_and_windows_repair_verification", + "source_frozen": false, + "previous_candidate_checkpoint": "rag/parent-delivery/snapshots/84de74a/release-candidate-checkpoint.json", + "product_ci": { + "status": "failure", + "source_commit": "c97b431cbcb75e07a45cc17de6d688442b736af3", + "head_commit": "c97b431cbcb75e07a45cc17de6d688442b736af3", + "url": "https://github.com/kitepon/peertable/actions/runs/36485234948", + "native_os": { + "darwin": "passed", + "linux": "passed", + "win32": "failed" + }, + "failure_evidence": "rag/parent-delivery/windows-rename-ci-diagnosis.json" + }, + "focused_verification": { + "darwin_process_exit": { + "passed": 2, + "failed": 0 + }, + "parent_delivery_and_harness_identity": { + "passed": 29, + "failed": 0 + }, + "native_fixture_and_lease_entry": { + "passed": 24, + "failed": 0 + }, + "scenario_transcript_observation": { + "passed": 22, + "failed": 0 + }, + "initial_codex_probe_deadline": { + "passed": 1, + "failed": 0 + }, + "source_runtime_and_independent_probe_timer": { + "passed": 3, + "failed": 0 + }, + "native_hook_fault_and_surface_registration": { + "passed": 36, + "failed": 0 + }, + "codex_real_os_probe_deadline": { + "product_live": false, + "health_delay_ms": 10, + "formal_acceptance_credit": 0, + "evidence": "rag/parent-delivery/codex-probe-timer-repair-diagnosis.json" + }, + "rejoin_probe_record_preservation": { + "passed": 1, + "failed": 0 + }, + "native_probe_fault_controller": { + "passed": 32, + "failed": 0, + "live_acceptance_credit": 0 + }, + "codex_spawn_lease_controller": { + "passed": 23, + "failed": 0, + "live_acceptance_credit": 0 + } + }, + "documentation_tests": { + "passed": 6, + "failed": 0, + "skipped": 0 + }, + "package": { + "status": "fixture_installed_acceptance_held", + "sha256": "823336e4397dabc77001d03dc57089822e1b3e48fbd2b4248623323007796329", + "files": 133, + "dry_run_files": 133, + "installed_runtime_digest": "ddaadee7f33139c2a7ea8d08b36d87446b3221b85c817b9a3e1afff34193186f", + "registry_install": false, + "skill_placement": { + "status": "required", + "reason": "専用prefixのfixture導入はignore-scriptsを使い、既存のglobalスキル配置を変更していない。registryのglobal installは公開後に行う。" + }, + "parent_connections": { + "codex": "registered_fixed_candidate_for_diagnostic", + "claude": "previous_candidate", + "cursor": "previous_candidate", + "grok": "previous_candidate" + } + }, + "product_acceptance": { + "manifest": "rag/parent-delivery/product-acceptance.json", + "status": "failed", + "expected": 525, + "records": 0, + "errors": { + "PARENT_ACCEPTANCE_MISSING": 525 + }, + "checkpoint_counts_as_live_acceptance": false + }, + "lease": { + "status": "withdrawn_monitors_finished_cleanup_review_pending", + "reported_live_before_withdrawal": 2, + "passed": 0, + "source_commit": "84de74a097469ef4abd43698635805696b1302ff", + "reason": "製品runtimeの再登録欠陥で候補撤回。原記録を保持し、所有試験だけを停止する。", + "active": 0, + "cleanup_status": "worker報告は自己processゼロ。rootの原記録監査は進行中。" + }, + "gui_observation": { + "status": "blocked", + "tool": "jev-desktop", + "alternative_permission_pending": true, + "test_windows_closed": true + }, + "published": false, + "production_deployed": false, + "registry_install": false, + "previous_product_ci": { + "status": "success", + "source_commit": "356f9c408bfb8a46327651130b513b21b1b2842a", + "head_commit": "590f5a8b65c0a00b7b0877495da2c6a65428101c", + "url": "https://github.com/kitepon/peertable/actions/runs/36478777216", + "native_os": { + "darwin": "passed", + "linux": "passed", + "win32": "passed" + } + }, + "repair_commit": "bce891b2cbf3a77afa7ab738e19f965e7ac9808c", + "withdrawal_evidence": "rag/parent-delivery/codex-probe-rejoin-diagnosis.json", + "controller_observation_limit_ms": 1500, + "repairs": { + "initial_probe_timeout": { + "state": "implemented_focused_passed_live_pending", + "evidence": "rag/parent-delivery/linux-grok-probe-latency-diagnosis.json" + }, + "windows_atomic_replace": { + "state": "diagnostic_pack_focused_passed_new_git_ci_pending", + "evidence": "rag/parent-delivery/windows-rename-ci-diagnosis.json" + }, + "codex_rejoin": { + "state": "diagnostic_recovered_new_adapter_live_pending", + "evidence": "rag/parent-delivery/codex-probe-rejoin-repair-diagnosis.json" + } + } +} diff --git a/rag/parent-delivery/windows-rename-ci-diagnosis.json b/rag/parent-delivery/windows-rename-ci-diagnosis.json index e2ac63d5..4ec402ec 100644 --- a/rag/parent-delivery/windows-rename-ci-diagnosis.json +++ b/rag/parent-delivery/windows-rename-ci-diagnosis.json @@ -17,7 +17,7 @@ "raw_log": "/private/tmp/peertable-windows-rename-c97b431/ci-failed.log", "raw_log_sha256": "dbbc60e991f86efaeaf99c3008ae9057cc3e1c4d6373a60507971db5cf8272d8", "baseline_same_runtime_ci": "https://github.com/kitepon/peertable/actions/runs/36484812856", - "diagnosis_status": "rootがWin32 API対照と実Node保持readerの原記録を照合。Windows OS adapterを修理し、診断packでfocused31件合格。新Git候補の3 OS CIと正式実機受入は未完了。", + "diagnosis_status": "rootがWin32 API対照と実Node保持readerの原記録を照合。Windows OS adapterを修理し、診断packでfocused31件合格。修理版の3 OS CIも合格。Windows Cursor排他待ちの別原因照合と正式実機受入は未完了。", "limitations": [ "元CIでreader handleを直接追跡しておらず、元CIのhandle保持processの特定は未確認", "診断packは未commit修理の原bytes。正式受入credit0", @@ -267,7 +267,14 @@ ] }, "cleanup_environment_raw_sha256": "1b368634d21a35fac3108a59a4cf17d44512467380eec5487d99ab92e0dba252", - "remote_readonly_environment_raw_sha256": "201c5eb8a017497f489e1681f25972560be8b3d0c6317bcfcae09e252d2344e3" + "remote_readonly_environment_raw_sha256": "201c5eb8a017497f489e1681f25972560be8b3d0c6317bcfcae09e252d2344e3", + "followup_ci": { + "source_commit": "980cbebc1acab7461daa064abec8814b410f0137", + "url": "https://github.com/kitepon/peertable/actions/runs/36488941014", + "darwin": "passed", + "linux": "passed", + "win32": "passed" + } }, "raw_fingerprints": { "ci-win-eperm/move-replace-probe.ps1": "cd2385a3ecef71be6308bf8ba4ffc8edc05c2f347d7ba38a8c91ee66c7a0e7b8", From 774236f1168910f38d217e21c8d963d1b4b4e5db Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:13:13 +0900 Subject: [PATCH 67/72] =?UTF-8?q?=E8=A6=AA=E9=85=8D=E9=80=81=E3=81=AE?= =?UTF-8?q?=E5=8F=97=E5=85=A5=E6=89=8B=E9=A0=86=E3=82=92=E5=AE=9F=E8=A6=B3?= =?UTF-8?q?=E6=B8=AC=E3=81=B8=E7=85=A7=E5=90=88=E3=81=97=E6=97=A7lease?= =?UTF-8?q?=E3=81=AE=E5=81=9C=E6=AD=A2=E8=A8=98=E9=8C=B2=E3=82=92=E8=A8=82?= =?UTF-8?q?=E6=AD=A3=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plan_parent-native-delivery.md | 6 +- experiments/parent-product-acceptance/run.mjs | 2 +- .../runner-provenance.mjs | 11 +- .../runner-provenance.test.mjs | 10 +- .../scenarios-lease-run.mjs | 2 +- .../parent-product-acceptance/scenarios.mjs | 6 +- .../codex-caller-deadline-comparison.json | 131 ++++++++++++++++++ .../release-candidate-checkpoint.json | 13 +- .../withdrawn-lease-stop-audit.json | 75 ++++++++++ .../parent-delivery-acceptance-contract.mjs | 12 ++ scripts/parent-delivery-acceptance.mjs | 39 ++++-- scripts/parent-delivery-acceptance.test.mjs | 44 +++++- 12 files changed, 329 insertions(+), 22 deletions(-) create mode 100644 rag/parent-delivery/codex-caller-deadline-comparison.json create mode 100644 rag/parent-delivery/withdrawn-lease-stop-audit.json create mode 100644 scripts/parent-delivery-acceptance-contract.mjs diff --git a/docs/plan_parent-native-delivery.md b/docs/plan_parent-native-delivery.md index 3e1b62fe..25c041e6 100644 --- a/docs/plan_parent-native-delivery.md +++ b/docs/plan_parent-native-delivery.md @@ -396,10 +396,14 @@ controllerはCursorの公式postToolUseが確定した入力全体を照合す 現行manifestの正式受入は再測定中で、長時間leaseの合格もまだ無い。Linux Cursorは本人ログイン待ち、Desktop/IDEはJevの画面取得の解決または操作ツール変更の回答待ちである。新候補の3 OS製品CI、全必須面の実機受入、main着地、npm公開・registry導入・本番反映を順に完了する。 -正式probe期限試験のcontrollerは、製品の実30秒期限と失敗の観測時刻を分ける。全OS・全harnessに共通の観測上限は既存focused timer試験と同じ1500msとし、失敗の実lagを原値で保存する。期限前、時刻欠落、health不一致、上限超過をtyped failureにし、製品deadlineを延ばして合格へ丸めない。Windowsの所有child停止APIと各harnessの故障発火が実測されていない行は、引き続き未合格とする。 +正式probe期限試験のcontrollerは、製品が保存した実probe期限と失敗の観測時刻を分ける。全OS・全harnessに共通の観測上限は既存focused timer試験と同じ1500msとし、失敗の実lagを原値で保存する。期限前、時刻欠落、health不一致、上限超過をtyped failureにし、製品deadlineを延ばして合格へ丸めない。Windowsの所有child停止APIと各harnessの故障発火が実測されていない行は、引き続き未合格とする。 再登録修理後のWindows CIでは、Bakery ticketのatomic置換で実`EPERM`が発生した。[原記録とAPI対照](../rag/parent-delivery/windows-rename-ci-diagnosis.json)から読取りhandle保持中の通常renameを再現し、Windows標準のPOSIX互換APIで修理した。診断packのfocused試験と修理版の3 OS CIは合格した。Linux Grokの初回probeは正常な本文回収時間を測って期限を調整した。Windows Cursorのlock timeoutは別に原因照合が残る。正式配送試験と公開は、新候補でその境界を確認してから進める。 [Linux Grokの正規通知の実診断](../rag/parent-delivery/linux-grok-probe-latency-diagnosis.json)では、本文回収まで53.198秒、次の受信登録まで71.99秒かかった。初回probe期限を共通120秒へ調整し、束縛期限を維持する。期限失敗を成功へ数えず、新候補で正常な確認と実期限失敗の両方を再測定する。 [Windowsの保持reader対照](../rag/parent-delivery/windows-rename-ci-diagnosis.json)で、libuvの通常置換APIが共有DELETE付きでもACCESS_DENIEDとなり、POSIX互換置換APIでは同じ保持reader下で成立することを確認した。Windowsのatomic更新だけを標準APIへ適合し、排他・spool・receiptの仕組みは全OS共通に保つ。診断packのfocused31件と新Git候補の3 OS CIは合格した。新候補の正常Grok probe・Windows Cursor排他待ちの診断と正式実機受入は未完了。 + +Codexの`binding_deadline`前半は、[公式callerの比較](../rag/parent-delivery/codex-caller-deadline-comparison.json)に従い、receiver準備の実RPC期限を測る。AitermとPeertableのCodexは公式MCPの会話IDを直接取得するため、事前hook記録の30秒待ちは存在しない。非Codexの束縛期限と、全harnessのprobe期限・正規再登録・復旧後DMを維持する。受入gateは24scenarioの手順・順序・run・会話・artifactを正本の手順と照合し、専用会話は該当stepの宣言と観測がそろった場合だけ相関する。各scenarioの実機成立は引き続き未判定である。 + +旧leaseの「wrapper終了」はcontrollerの後片付け完了ではなかった。[停止記録の監査](../rag/parent-delivery/withdrawn-lease-stop-audit.json)で、旧入口には中断handlerがなく、summaryが実行中のまま残ることを確認した。旧Mac/Linux Claudeの既知processはrootの読取確認でも存命0だが、旧runを正常終了やlease合格にしない。新lease開始前に中断時の後片付けと失敗記録を修理する。 diff --git a/experiments/parent-product-acceptance/run.mjs b/experiments/parent-product-acceptance/run.mjs index f18df905..1d07ef0f 100644 --- a/experiments/parent-product-acceptance/run.mjs +++ b/experiments/parent-product-acceptance/run.mjs @@ -252,7 +252,7 @@ try { const seen = active === context ? await until('transcript末尾の確定', () => { const value = observe(); return value.pending_tail === 0 ? value : null }, 30000, 1000) : null if (seen) copyFileSync(file, join(privateDir, `transcript-${meta.parent_session}.jsonl`)) const evidence = buildCase({ meta: caseMeta, scenario: name, checks: measured.checks, observations: measured.observations, - extra: { run_id: measured.run_id, trace: measured.trace, boundaries: measured.boundaries, + extra: { run_id: measured.run_id, trace: measured.trace, page_chars: measured.page_chars, boundaries: measured.boundaries, ...(seen ? { transcript_rows: seen.rows, turns_seen: seen.turns } : {}) } }) writeCase(evidence) } diff --git a/experiments/parent-product-acceptance/runner-provenance.mjs b/experiments/parent-product-acceptance/runner-provenance.mjs index 3c80de7c..f2d1efd2 100644 --- a/experiments/parent-product-acceptance/runner-provenance.mjs +++ b/experiments/parent-product-acceptance/runner-provenance.mjs @@ -17,5 +17,14 @@ export function runnerProvenance(directory, commit) { if (!actual.equals(committed)) fail(`${name}は指定したcontroller commitと一致しません`) return [name, sha(actual)] })) - return { commit, modules_sha256, product_source_is_separate: true } + // 目録とgateも実行時依存。controllerだけの指紋へ隠さない。 + const contract_files_sha256 = Object.fromEntries(['parent-delivery-acceptance.mjs', 'parent-delivery-acceptance-contract.mjs'].map(name => { + const path = `scripts/${name}`, actual = readFileSync(join(directory, '../../', path)) + let committed + try { committed = execFileSync('git', ['show', `${commit}:${path}`], { cwd: directory, stdio: ['ignore', 'pipe', 'pipe'] }) } + catch { fail(`${path}のGit blobを指定したcontroller commitから取得できません`) } + if (!actual.equals(committed)) fail(`${path}は指定したcontroller commitと一致しません`) + return [path, sha(actual)] + })) + return { commit, modules_sha256, contract_files_sha256, product_source_is_separate: true } } diff --git a/experiments/parent-product-acceptance/runner-provenance.test.mjs b/experiments/parent-product-acceptance/runner-provenance.test.mjs index e0ec8c92..04a1ad49 100644 --- a/experiments/parent-product-acceptance/runner-provenance.test.mjs +++ b/experiments/parent-product-acceptance/runner-provenance.test.mjs @@ -13,10 +13,18 @@ test('実行controllerは指定commitの実物に一致し、未commit差分と mkdirSync(dir, { recursive: true }); mkdirSync(hooks) const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() git('init', '--quiet'); writeFileSync(join(dir, 'controller.mjs'), 'export const label = "実行する内容"\n') - writeFileSync(join(dir, 'README.md'), '# 試験\n'); git('add', '--', 'experiments') + writeFileSync(join(dir, 'README.md'), '# 試験\n') + mkdirSync(join(root, 'scripts')) + for (const file of ['parent-delivery-acceptance.mjs', 'parent-delivery-acceptance-contract.mjs']) writeFileSync(join(root, 'scripts', file), '// 受入契約\n') + git('add', '--', 'experiments', 'scripts') git('-c', `core.hooksPath=${hooks}`, '-c', 'user.name=試験', '-c', 'user.email=fixture@example.invalid', 'commit', '--quiet', '-m', 'controllerの試験用正本') const commit = git('rev-parse', 'HEAD'), proof = runnerProvenance(dir, commit) assert.equal(proof.commit, commit); assert.deepEqual(Object.keys(proof.modules_sha256), ['README.md', 'controller.mjs']) + assert.deepEqual(Object.keys(proof.contract_files_sha256), ['scripts/parent-delivery-acceptance.mjs', 'scripts/parent-delivery-acceptance-contract.mjs']) + const contract = join(root, 'scripts/parent-delivery-acceptance-contract.mjs') + writeFileSync(contract, '// 異なる期待値\n') + assert.throws(() => runnerProvenance(dir, commit), { code: 'ACCEPTANCE_RUNNER_PROVENANCE_MISMATCH' }) + writeFileSync(contract, '// 受入契約\n') writeFileSync(join(dir, 'controller.mjs'), 'export const label = "異なる内容"\n') assert.throws(() => runnerProvenance(dir, commit), { code: 'ACCEPTANCE_RUNNER_PROVENANCE_MISMATCH' }) assert.throws(() => runnerProvenance(dir, '0'.repeat(40)), { code: 'ACCEPTANCE_RUNNER_PROVENANCE_MISMATCH' }) diff --git a/experiments/parent-product-acceptance/scenarios-lease-run.mjs b/experiments/parent-product-acceptance/scenarios-lease-run.mjs index 66c61f03..183e6f24 100644 --- a/experiments/parent-product-acceptance/scenarios-lease-run.mjs +++ b/experiments/parent-product-acceptance/scenarios-lease-run.mjs @@ -43,7 +43,7 @@ export async function runLeaseOnly(options) { log('native_parent_verified', { parent_session: context.session, parent_process: context.caseMeta.parent_process, endpoint_id: context.caseMeta.endpoint_id }) for (const [name, action] of Object.entries(context.actions)) context.actions[name] = async (input, scope) => { log(`${name}:started`); const result = await action(input, scope); log(`${name}:observed`, { expectation: result.expectation }); return result } const measured = await runScenario('lease', context) - const evidence = buildCase({ meta: context.caseMeta, scenario: 'lease', checks: measured.checks, observations: measured.observations, extra: { trace: measured.trace, run_id: measured.run_id, boundary_contract: measured.boundaries } }) + const evidence = buildCase({ meta: context.caseMeta, scenario: 'lease', checks: measured.checks, observations: measured.observations, extra: { trace: measured.trace, page_chars: measured.page_chars, run_id: measured.run_id, boundary_contract: measured.boundaries } }) const evidenceFile = `rag/parent-delivery/live/${evidence.case_id}.json`, file = join(out, 'public', evidenceFile); mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, JSON.stringify(evidence, null, 2)) const record = buildRecord(evidence, evidenceFile), audit = selfAudit(record, evidence); acceptCase(record, audit) summary.cases.push({ ...record, gate_errors: audit.gate_errors }); writeFileSync(join(out, 'records.json'), JSON.stringify({ records: [record] }, null, 2)) diff --git a/experiments/parent-product-acceptance/scenarios.mjs b/experiments/parent-product-acceptance/scenarios.mjs index d84c10d6..f1e81203 100644 --- a/experiments/parent-product-acceptance/scenarios.mjs +++ b/experiments/parent-product-acceptance/scenarios.mjs @@ -1,7 +1,7 @@ // 実機受入24scenarioの手順。公式親の会話と実境界の観測が揃うまで合格を作らない。 import { randomUUID } from 'node:crypto' import { isDeepStrictEqual } from 'node:util' -import { scenarios as inventory } from '../../scripts/parent-delivery-acceptance.mjs' +import { scenarios as inventory } from '../../scripts/parent-delivery-acceptance-contract.mjs' export const scenarioNames = Object.keys(inventory).filter(name => name !== 'audience') const fail = (code, detail) => { throw Object.assign(new Error(detail), { code }) } @@ -98,10 +98,10 @@ export async function runScenario(name, context) { if (nonces.has(check.nonce)) fail('ACCEPTANCE_SCENARIO_EVIDENCE_REUSED', `${name}: 同じ観測を再使用しています`) nonces.add(check.nonce); checks.push(check) } - trace.push({ step: index, action: item.action, expectation: item.expectation, artifacts: result.observations.map(row => row.artifact) }) + trace.push({ step: index, action: item.action, expectation: item.expectation, artifacts: result.observations.map(row => row.artifact), ...(result.related_sessions ? { related_sessions: result.related_sessions } : {}) }) } if (!checks.length) fail('ACCEPTANCE_NO_NATIVE_DELIVERY', `${name}: 専用本文の実親受信がありません`) if (['consecutive', 'source_reconnect', 'burst'].includes(name) && checks.some((check, index) => index && (check.original.seq <= checks[index - 1].original.seq || check.delivery.order <= checks[index - 1].delivery.order))) fail('ACCEPTANCE_SEQ_ORDER', `${name}: seq順に配送されていません`) - return { scenario: name, run_id: runId, checks, observations, trace, boundaries: receiverBoundaries[harness], status: 'passed' } + return { scenario: name, run_id: runId, page_chars: pageChars, checks, observations, trace, boundaries: receiverBoundaries[harness], status: 'passed' } } finally { if (context.finalize) await context.finalize(scope) } } diff --git a/rag/parent-delivery/codex-caller-deadline-comparison.json b/rag/parent-delivery/codex-caller-deadline-comparison.json new file mode 100644 index 00000000..dbeea32b --- /dev/null +++ b/rag/parent-delivery/codex-caller-deadline-comparison.json @@ -0,0 +1,131 @@ +{ + "schema": "peertable.codex-caller-deadline-comparison.v1", + "diagnostic_only": true, + "formal_acceptance_credit": 0, + "root_audited": true, + "source_fingerprint_sha256": "eb6f0775fcdf536d68554b9d1ca97adc726d7ab34a74e11889f5a73f900cfb41", + "repositories": [ + { + "commit": "edd19ca020c51bd7311546e5ead772e8d53fac96", + "files": [ + { + "path": "room/parent-client.mjs", + "sha256": "856d1f80000d3468f18dc0adb4954086c6648a996044ee6801312b7236963c49", + "root_verified_working_snapshot_git_bytes_equal": true + }, + { + "path": "skill/scripts/parent-caller.mjs", + "sha256": "80227b6ec2a848e1b1a1546cafa8608c4513c46b22f4ee9d09f415ae00134dc6", + "root_verified_working_snapshot_git_bytes_equal": true + }, + { + "path": "skill/scripts/parent-platform.mjs", + "sha256": "45b06cf65ee6413b795fa0f00225250fc5f9b42164268b31a6b39fe4440c6d34", + "root_verified_working_snapshot_git_bytes_equal": true + }, + { + "path": "skill/scripts/parent-receivers/codex.mjs", + "sha256": "6342cffb5727c0d1cd8aa7ae3ad49c704bf7408f18a7b01b9e7962cb02939c07", + "root_verified_working_snapshot_git_bytes_equal": true + }, + { + "path": "skill/scripts/parent-runtime.mjs", + "sha256": "decc27240f144753b371220dd14abca170b4d166f05abc216ee7a60246b815c9", + "root_verified_working_snapshot_git_bytes_equal": true + }, + { + "path": "skill/scripts/parent-delivery.mjs", + "sha256": "390ca733e9dd1f6a36ecda01166b176821fb03962a9b65f2a8483e05199f8f9c", + "root_verified_working_snapshot_git_bytes_equal": true + }, + { + "path": "skill/scripts/parent-watch.mjs", + "sha256": "374523f6a7127e4bded108911e2740fa34eb0b2216d0716e7a425aca6fc85183", + "root_verified_working_snapshot_git_bytes_equal": true + }, + { + "path": "skill/scripts/parent-prepare.mjs", + "sha256": "310f0fbbfe2d954c5644c4f9bf610bc1301c938b8592b6307b69e43da04fd05f", + "root_verified_working_snapshot_git_bytes_equal": true + }, + { + "path": "experiments/parent-product-acceptance/scenarios.mjs", + "sha256": "08db776c879b02b8da7b6263511256b192d35a6d78fdaf1164e05328184fca98", + "root_verified_working_snapshot_git_bytes_equal": true + }, + { + "path": "experiments/parent-product-acceptance/scenarios-native.mjs", + "sha256": "0c22c42d40ae9cf42a4f202a8d081abda564b51b27ec0e9c55118ab95af4d194", + "root_verified_working_snapshot_git_bytes_equal": true + }, + { + "path": "docs/plan_parent-native-delivery.md", + "sha256": "b7a552dd5d2eacfcbedbd76ee24dd88cd0e058eb3c0be7b44f5e84b2ec76b1dc", + "root_verified_working_snapshot_git_bytes_equal": true + } + ] + }, + { + "commit": "8cfe1da43a28606ccb53acd1dbfcc0607436add0", + "files": [ + { + "path": "src/index.ts", + "sha256": "82b5926a3326b03cd3570e43c0d104e821bfa13d8b8ae27b1cca84c4b45b6916", + "root_verified_working_snapshot_git_bytes_equal": true + }, + { + "path": "src/codex-parent-receiver.ts", + "sha256": "fd175962d6e5f37cb0ae030f97017bb7950e2108ae91f09565a5bb9a0906ef61", + "root_verified_working_snapshot_git_bytes_equal": true + }, + { + "path": "src/codex-parent-hooks.ts", + "sha256": "a13321cd93461e7e93879d6ab3cc9684ba05d4050829ed5b7a8f864ac0d6c586", + "root_verified_working_snapshot_git_bytes_equal": true + }, + { + "path": "src/codex-hook-state.ts", + "sha256": "db5fc0e39fe93338c20c8c160eeb4a602aca66ca0f0d7d6379fdb3b8beaf54b0", + "root_verified_working_snapshot_git_bytes_equal": true + }, + { + "path": "src/codex-relay-client.ts", + "sha256": "b06765ba7d1b7ac3b3964163ecc2a81e1a410ec91450ad80e6274ac56fd2d76b", + "root_verified_working_snapshot_git_bytes_equal": true + }, + { + "path": "src/parent-delivery.ts", + "sha256": "771582d0fbb54d255683847b414b1f4b2b85b555329b71e4d211e26fac558763", + "root_verified_working_snapshot_git_bytes_equal": true + }, + { + "path": "src/codex-delivery-error.ts", + "sha256": "ab69739dc8fc906b01eca7d9da59b0ab4a4e01ba8f6ba45375b11b4227006d2f", + "root_verified_working_snapshot_git_bytes_equal": true + }, + { + "path": "src/harnesses/codex.ts", + "sha256": "d22751d2174cfce55d0f613970379c378bc5acd868cc9763bf962cbea8e35f93", + "root_verified_working_snapshot_git_bytes_equal": true + } + ] + } + ], + "facts": { + "official_caller_binding": "公式MCP clientと当該requestのthreadIdを同期で取得し、実storeの公式receiverを検証する。Codexでは事前hook contextを待たない。", + "codex_hook_binding_pending_exists": false, + "codex_hook_binding_30s_timeout_exists": false, + "non_codex_hook_context_timeout_ms": 30000, + "receiver_preparation_timeout_ms": 15000, + "receiver_preparation_failure": "PARENT_CODEX_RPC_TIMEOUT", + "receiver_failure_precedes_new_endpoint_creation": true, + "product_probe_timeout_source": "skill/scripts/parent-delivery.mjs:PARENT_PROBE_TIMEOUT_MS", + "prepare_cli_receipt_is_not_mcp_caller_pending": true + }, + "decision": "Codex binding_deadlineの前半は実在するreceiver準備期限failureを実測する。非Codexのhook束縛期限、後半probe期限・同会話復旧・DMは維持する。25scenario・525項目は維持し、未実測を合格へ置換しない。", + "limitations": [ + "読取診断は実機期限failureを証明しない。新controllerの実測は未完了。", + "AitermとPeertableの公式受信口契約を比較した。所有state/binary選択のコード実装が逐語一致するという意味ではない。", + "旧診断は公式MCP inputの_meta原bytesを保存していない。新fixtureでは受動記録する。" + ] +} diff --git a/rag/parent-delivery/release-candidate-checkpoint.json b/rag/parent-delivery/release-candidate-checkpoint.json index 1418f1b8..58d25b33 100644 --- a/rag/parent-delivery/release-candidate-checkpoint.json +++ b/rag/parent-delivery/release-candidate-checkpoint.json @@ -77,6 +77,16 @@ "passed": 25, "failed": 0, "live_acceptance_credit": 0 + }, + "acceptance_step_gate": { + "passed": 16, + "failed": 0, + "live_acceptance_credit": 0 + }, + "acceptance_controller_contract_provenance": { + "passed": 1, + "failed": 0, + "live_acceptance_credit": 0 } }, "documentation_tests": { @@ -119,7 +129,8 @@ "source_commit": "84de74a097469ef4abd43698635805696b1302ff", "reason": "製品runtimeの再登録欠陥で候補撤回。原記録を保持し、所有試験だけを停止する。", "active": 0, - "cleanup_status": "worker報告は自己processゼロ。rootの原記録監査は進行中。" + "cleanup_status": "rootは旧Mac/Linux Claude既知processの存命0を独立確認。旧controller finallyは未実行でsummary running残留。新run前に中断入口の修理が必要。", + "cleanup_evidence": "rag/parent-delivery/withdrawn-lease-stop-audit.json" }, "gui_observation": { "status": "blocked", diff --git a/rag/parent-delivery/withdrawn-lease-stop-audit.json b/rag/parent-delivery/withdrawn-lease-stop-audit.json new file mode 100644 index 00000000..a2302d97 --- /dev/null +++ b/rag/parent-delivery/withdrawn-lease-stop-audit.json @@ -0,0 +1,75 @@ +{ + "schema": "peertable.withdrawn-lease-stop-audit.v1", + "formal_acceptance_credit": 0, + "controller_commit": "843ca88", + "controller_source_sha256": "6348a310b1f56a350eb6c38c3bcd6b729bef369da408229a1a837a55f024becc", + "signal_handler_present": false, + "controller_summary_running_retained": true, + "wrapper_finished_is_controller_finally_proof": false, + "stop_mechanism": "外側で所有processを停止・回収。controllerの正常終了ではない。", + "root_process_checks": [ + { + "os": "darwin", + "pids": [ + 28456, + 28560, + 28578, + 28743, + 28865, + 29065, + 29104, + 35619, + 32373 + ], + "checked_at": "2026-09-28T22:11:37.726013+00:00", + "command": [ + "/bin/ps", + "-p", + "28456,28560,28578,28743,28865,29065,29104,35619,32373", + "-o", + "pid=", + "-o", + "lstart=", + "-o", + "comm=" + ], + "exit_code": 1, + "stdout": "", + "stderr": "", + "alive_now": 0, + "worker_snapshot_sha256": "93e98a1f8d065428c9acbc371529543b1f83a5ec6a281243440a0ec4cb8647f5", + "limitation": "存命processがない確認。終了時にcontroller finallyが実行された証拠ではない。Linux wrapper/paneは保存された開始identityがない。" + }, + { + "os": "linux", + "pids": [ + 603044, + 603384, + 603440, + 603468, + 603514, + 603851, + 603858, + 614516, + 605987 + ], + "checked_at": "2026-09-28T22:11:38.378291+00:00", + "command": [ + "ssh", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=10", + "main-server", + "ps -p 603044,603384,603440,603468,603514,603851,603858,614516,605987 -o pid= -o lstart= -o comm=" + ], + "exit_code": 1, + "stdout": "", + "stderr": "", + "alive_now": 0, + "worker_snapshot_sha256": "ba957d3597cb55109a03ba3b3fa32275a77e44b0d80d1c0b1931d53723a4ea97", + "limitation": "存命processがない確認。終了時にcontroller finallyが実行された証拠ではない。Linux wrapper/paneは保存された開始identityがない。" + } + ], + "remaining": "新leaseの開始前にcontroller中断でcleanupと失敗summaryを完了させる。endpoint/port/configの完全な終了時証跡は別途照合する。" +} diff --git a/scripts/parent-delivery-acceptance-contract.mjs b/scripts/parent-delivery-acceptance-contract.mjs new file mode 100644 index 00000000..9be3a3f9 --- /dev/null +++ b/scripts/parent-delivery-acceptance-contract.mjs @@ -0,0 +1,12 @@ +// 必須scenarioの目録正本。受入手順と公開gateで共有する。 +export const scenarios = { + audience: 'DM・親を含む複数宛・all各1回、送信者/room/seq/宛先/原文一致', + busy: '作業を保持して同じ会話へ配送', final_race: '最終応答/終了競合で欠落重複なし', idle: 'オーナー入力なしで同じ会話が再開', + consecutive: 'seq順の連続配送と2通目以降の待機', no_external_tools: '外部作業toolなしでも受信維持', no_tools: '全tool省略時の維持または明示rearm_pendingと原文保持', + original: '日本語・改行・引用・長文の全量回収', burst: '回収上限超過の未読保持', source_reconnect: 'HTTP/SSE再接続catch-upで重複欠落なし', + process_recovery: 'ready継承/sending unknown/submitted再送なし', claim_race: '受信口間claimで1回だけ出力', output_interruption: '削除/claim後中断はunknownと原文保持', + receipt_retry: '受付後のreceipt失敗はreceiptだけ復旧', session_change: 'clear/終了/別会話/resumeで旧本文誤流入なし', parallel_rooms: '複数room/親会話の所有分離', + unavailable_hook: '無効/未承認/実行file欠落は原因付き失敗', foreign_ownership: '利用者/Aitermのqueue・hook・承認・順序保持', compatibility_hooks: '互換hookの二重相関なし', + background_end: 'task cancel/timeout/終了は成功へ丸めない', lease: '有限lease更新とendpoint/cursor/本文保持', binding_deadline: '束縛/probe期限のtyped failure', + slot_race: '複数hook開始でも1slot', lifecycle: '同版/新版更新・teardownで親processと履歴保持', package: 'npm packの配布物から同じ経路が成立。公開後もregistry版で再確認', +} diff --git a/scripts/parent-delivery-acceptance.mjs b/scripts/parent-delivery-acceptance.mjs index 553be232..ac126069 100644 --- a/scripts/parent-delivery-acceptance.mjs +++ b/scripts/parent-delivery-acceptance.mjs @@ -8,17 +8,10 @@ import { isDeepStrictEqual } from 'node:util' import { runtimeDigest } from '../skill/scripts/runtime-digest.mjs' const root = fileURLToPath(new URL('../', import.meta.url)) export const surfaces = { claude: ['cli'], codex: ['desktop', 'ide', 'cli'], grok: ['cli'], cursor: ['desktop', 'cli'] } -export const scenarios = { - audience: 'DM・親を含む複数宛・all各1回、送信者/room/seq/宛先/原文一致', - busy: '作業を保持して同じ会話へ配送', final_race: '最終応答/終了競合で欠落重複なし', idle: 'オーナー入力なしで同じ会話が再開', - consecutive: 'seq順の連続配送と2通目以降の待機', no_external_tools: '外部作業toolなしでも受信維持', no_tools: '全tool省略時の維持または明示rearm_pendingと原文保持', - original: '日本語・改行・引用・長文の全量回収', burst: '回収上限超過の未読保持', source_reconnect: 'HTTP/SSE再接続catch-upで重複欠落なし', - process_recovery: 'ready継承/sending unknown/submitted再送なし', claim_race: '受信口間claimで1回だけ出力', output_interruption: '削除/claim後中断はunknownと原文保持', - receipt_retry: '受付後のreceipt失敗はreceiptだけ復旧', session_change: 'clear/終了/別会話/resumeで旧本文誤流入なし', parallel_rooms: '複数room/親会話の所有分離', - unavailable_hook: '無効/未承認/実行file欠落は原因付き失敗', foreign_ownership: '利用者/Aitermのqueue・hook・承認・順序保持', compatibility_hooks: '互換hookの二重相関なし', - background_end: 'task cancel/timeout/終了は成功へ丸めない', lease: '有限lease更新とendpoint/cursor/本文保持', binding_deadline: '束縛/probe期限のtyped failure', - slot_race: '複数hook開始でも1slot', lifecycle: '同版/新版更新・teardownで親processと履歴保持', package: 'npm packの配布物から同じ経路が成立。公開後もregistry版で再確認', -} +export { scenarios } from './parent-delivery-acceptance-contract.mjs' +import { scenarios } from './parent-delivery-acceptance-contract.mjs' +import { scenarioPlan, validateBoundary } from '../experiments/parent-product-acceptance/scenarios.mjs' + export const adapterObservations = { claude: ['実session/tool_use_idとMCP claudecode/toolUseId', 'PostToolUse/Stop asyncRewakeのnative hook_response exit2と後続assistant', 'PID+開始identityのsession1slot'], codex: ['実MCP threadIdと実caller binary/store', 'queue受付IDと同期PostToolUse/Stopのdelete:true・同turn返信', 'idle公式queueの新turn起床・他所有queue不変更'], @@ -42,6 +35,24 @@ export function testedSourceCommit(records, { repo = root, sourceCommit } = {}) if (changed.length) throw Object.assign(new Error(`検証後に製品sourceが変わっています: ${changed.join(', ')}`), { code: 'PARENT_ACCEPTANCE_SOURCE_CHANGED' }) return tested } +// 各手順の観測を、その手順・run・会話・artifactへ戻して照合する。 +export function auditScenarioSteps(evidence, item, session) { + const invalid = detail => { throw Object.assign(new Error(detail), { code: 'PARENT_ACCEPTANCE_SCENARIO_INVALID' }) } + const plan = scenarioPlan(item.scenario, { harness: item.harness, pageChars: evidence.page_chars }) + if (!evidence.run_id || !Array.isArray(evidence.trace) || evidence.trace.length !== plan.length || !Array.isArray(evidence.observations)) invalid('手順またはrunの相関がありません') + const used = new Set() + for (const [index, expected] of plan.entries()) { + const actual = evidence.trace[index] + if (actual.step !== index || actual.action !== expected.action || actual.expectation !== expected.expectation || !Array.isArray(actual.artifacts) || !actual.artifacts.length + || (actual.related_sessions !== undefined && (!Array.isArray(actual.related_sessions) || actual.related_sessions.some(value => typeof value !== 'string' || !value)))) invalid('手順の期待値・順序・artifactが一致しません') + const rows = evidence.observations.filter(row => actual.artifacts.includes(row.artifact)) + if (rows.length !== actual.artifacts.length || new Set(actual.artifacts).size !== actual.artifacts.length || rows.some(row => row.kind !== expected.expectation || used.has(row.artifact))) invalid('手順の実観測が欠けているか再使用されています') + validateBoundary({ expectation: expected.expectation, verified: true, observations: rows, related_sessions: actual.related_sessions }, { expectation: expected.expectation, session, runId: evidence.run_id, scenario: item.scenario }) + for (const related of actual.related_sessions ?? []) if (!rows.some(row => row.parent_session === related)) invalid('観測のない別会話です') + for (const row of rows) used.add(row.artifact) + } + if (used.size !== evidence.observations.length) invalid('手順に属さない観測があります') +} export function auditAcceptance(records, { sourceCommit, sourceDigest, packageVersion, readEvidence = file => JSON.parse(readFileSync(resolve(root, file), 'utf8')) } = {}) { const byId = new Map(), errors = [] for (const record of records) { if (byId.has(record.id)) errors.push({ id: record.id, code: 'PARENT_ACCEPTANCE_DUPLICATE' }); byId.set(record.id, record) } @@ -53,11 +64,15 @@ export function auditAcceptance(records, { sourceCommit, sourceDigest, packageVe if (actual.kind === 'product_live' && actual.evidence_file) { try { const evidence = readEvidence(actual.evidence_file) + if (item.scenario !== 'audience') { + try { auditScenarioSteps(evidence, item, actual.parent_session) } + catch (error) { errors.push({ id: item.id, code: 'PARENT_ACCEPTANCE_SCENARIO_INVALID', detail: error.code ?? error.message }) } + } if (evidence.schema !== 'peertable.parent-live-case.v1' || evidence.case_id !== item.id || evidence.native !== true || evidence.source_commit !== actual.source_commit || evidence.harness_version !== actual.harness_version || evidence.parent_session !== actual.parent_session || evidence.runtime_digest !== actual.runtime_digest || evidence.package_version !== actual.package_version || evidence.os !== item.os || evidence.harness !== item.harness || evidence.surface !== item.surface - || evidence.owner_input_required !== false || !Array.isArray(evidence.observations) || !evidence.observations.length || evidence.observations.some(event => !event.kind || event.parent_session !== actual.parent_session || !event.turn_id) + || evidence.owner_input_required !== false || !Array.isArray(evidence.observations) || !evidence.observations.length || evidence.observations.some(event => !event.kind || (item.scenario === 'audience' && event.parent_session !== actual.parent_session) || !event.turn_id) || !Array.isArray(evidence.receipts) || !evidence.receipts.length || evidence.receipts.some(receipt => !Number.isSafeInteger(receipt.seq) || receipt.seq <= 0 || !receipt.recipient || receipt.route !== 'parent_receiver' || !['delivered', 'failed', 'unknown'].includes(receipt.result) || !Number.isSafeInteger(receipt.receipt_revision) || receipt.receipt_revision <= 0 || (item.harness === 'codex' && receipt.result === 'delivered' && (!receipt.queued_submission_id || !receipt.accepted_at))) || !Array.isArray(evidence.body_checks) || !evidence.body_checks.length diff --git a/scripts/parent-delivery-acceptance.test.mjs b/scripts/parent-delivery-acceptance.test.mjs index 267393a8..0006b119 100644 --- a/scripts/parent-delivery-acceptance.test.mjs +++ b/scripts/parent-delivery-acceptance.test.mjs @@ -1,12 +1,14 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { expectedCases, auditAcceptance, testedSourceCommit } from './parent-delivery-acceptance.mjs' +import { expectedCases, auditAcceptance, auditScenarioSteps, testedSourceCommit } from './parent-delivery-acceptance.mjs' import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { execFileSync } from 'node:child_process' +import { scenarioPlan } from '../experiments/parent-product-acceptance/scenarios.mjs' test('全12組合せの必須実行面は欠落/skip/fixtureを製品実機passedにしない', () => { const expected = expectedCases() + assert.equal(expected.length, 525) for (const os of ['darwin', 'linux', 'win32']) for (const harness of ['claude', 'codex', 'cursor', 'grok']) assert.ok(expected.some(item => item.os === os && item.harness === harness)) assert.equal(auditAcceptance([]).errors.length, expected.length) assert.equal(auditAcceptance([{ id: expected[0].id, status: 'passed', kind: 'fixture' }]).status, 'failed') @@ -42,3 +44,43 @@ test('証拠保存commitは検証済みsourceを包含し、別branchのsource const unrelated = git('rev-parse', 'HEAD'); git('checkout', '--quiet', branch) assert.throws(() => testedSourceCommit([{ source_commit: unrelated }], { repo }), { code: 'PARENT_ACCEPTANCE_SOURCE_NOT_INCLUDED' }) }) + +// fixtureはgateの照合だけを試し、実機受入の成績には使わない。 +const scenarioEvidence = (item, session = 'session') => { + const plan = scenarioPlan(item.scenario, { harness: item.harness, pageChars: 100 }) + return { run_id: 'run', page_chars: 100, + trace: plan.map((step, index) => ({ step: index, action: step.action, expectation: step.expectation, artifacts: [`/fixture/step-${index}.json`] })), + observations: plan.map((step, index) => ({ kind: step.expectation, run_id: 'run', scenario: item.scenario, parent_session: session, turn_id: 'turn', artifact: `/fixture/step-${index}.json`, source: 'official_hook' })) } +} +test('全harnessの24scenarioは各手順の期待値・順序・観測相関をgateで照合する', () => { + for (const item of expectedCases().filter(item => item.os === 'darwin' && item.scenario !== 'audience')) assert.doesNotThrow(() => auditScenarioSteps(scenarioEvidence(item), item, 'session')) + const item = { scenario: 'binding_deadline', harness: 'codex' } + for (const mutation of [ + evidence => evidence.trace.pop(), + evidence => evidence.trace.reverse(), + evidence => { evidence.trace[0].expectation = '別期待値' }, + evidence => { evidence.observations[0].kind = 'native_delivery' }, + evidence => { evidence.observations[0].run_id = '別run' }, + evidence => { evidence.observations[0].scenario = 'busy' }, + evidence => { evidence.observations[0].parent_session = '別会話' }, + evidence => { evidence.trace[1].artifacts = [...evidence.trace[0].artifacts] }, + evidence => evidence.observations.push({ ...evidence.observations[0], artifact: '/fixture/余剰.json' }), + ]) { + const evidence = scenarioEvidence(item); mutation(evidence) + assert.throws(() => auditScenarioSteps(evidence, item, 'session')) + } +}) +test('専用会話は該当stepの宣言とそのstepの実観測が両方ある場合だけ照合する', () => { + const item = { scenario: 'binding_deadline', harness: 'codex' }, evidence = scenarioEvidence(item) + evidence.observations[2].parent_session = '専用会話' + assert.throws(() => auditScenarioSteps(evidence, item, 'session')) + evidence.trace[2].related_sessions = ['専用会話'] + assert.doesNotThrow(() => auditScenarioSteps(evidence, item, 'session')) + evidence.trace[1].related_sessions = ['観測なし会話'] + assert.throws(() => auditScenarioSteps(evidence, item, 'session')) +}) +test('原文・上限超過の手順は導入物page上限を証拠に持つ', () => { + const item = { scenario: 'original', harness: 'claude' }, evidence = scenarioEvidence(item) + delete evidence.page_chars + assert.throws(() => auditScenarioSteps(evidence, item, 'session'), { code: 'ACCEPTANCE_PAGE_LIMIT_MISSING' }) +}) From e2a48a81ef022c13d6d3f7bb6f133969ff7a7054 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:17:58 +0900 Subject: [PATCH 68/72] =?UTF-8?q?Codex=E5=8F=97=E5=85=A5=E3=81=A7=E5=88=9D?= =?UTF-8?q?=E5=9B=9Ereceiver=E6=BA=96=E5=82=99=E3=81=AE=E5=AE=9FRPC?= =?UTF-8?q?=E6=9C=9F=E9=99=90=E3=82=92=E6=A4=9C=E8=A8=BC=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scenarios-native.mjs | 52 ++++++++- .../scenarios-receiver-fault.mjs | 110 ++++++++++++++++++ .../scenarios-receiver-fault.test.mjs | 87 ++++++++++++++ .../parent-product-acceptance/scenarios.mjs | 7 +- .../scenarios.test.mjs | 23 ++++ 5 files changed, 275 insertions(+), 4 deletions(-) create mode 100644 experiments/parent-product-acceptance/scenarios-receiver-fault.mjs create mode 100644 experiments/parent-product-acceptance/scenarios-receiver-fault.test.mjs diff --git a/experiments/parent-product-acceptance/scenarios-native.mjs b/experiments/parent-product-acceptance/scenarios-native.mjs index 768b0d1d..cce40e7c 100644 --- a/experiments/parent-product-acceptance/scenarios-native.mjs +++ b/experiments/parent-product-acceptance/scenarios-native.mjs @@ -10,6 +10,7 @@ import { createNativeFixtureFactory, waitOwnedFixtureExit } from './scenarios-fi import { sha256 } from './evidence.mjs' import { createBackgroundSurfaceAdapters, nativeInvocation } from './scenarios-surfaces.mjs' import { installQueueProbeFault, queueProbeReceiverProof, watchQueueProbeState, queueProbeTimeoutProof, codexProbeJoinBoundary, installQueueConnectionObserver, observedQueueConnections, QUEUE_PROBE_OBSERVATION_LIMIT_MS } from './scenarios-queue-fault.mjs' +import { installReceiverPreparationFault, codexReceiverFailureBoundary, receiverPreparationFailure } from './scenarios-receiver-fault.mjs' const fail = (code, message, detail) => { throw Object.assign(new Error(message), { code, detail }) } const rows = file => existsSync(file) ? readFileSync(file, 'utf8').split('\n').filter(Boolean).map(line => JSON.parse(line)) : [] @@ -249,6 +250,41 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { const confirmOne = s => { const last = s.checks.at(-1); if (!last || last.count !== 1 || last.status !== 'passed') fail('ACCEPTANCE_NATIVE_DELIVERY_MISSING', s.scenario); return last } const actions = {} + actions.receiver_preparation_timeout_arm = async (_, s) => { + if (s.harness !== 'codex') sourceUnconfirmed(s.harness, 'Codex同期receiver準備の応答期限') + const runner = s.meta.runner + if (!/^[0-9a-f]{40}$/u.test(runner?.commit ?? '') || runner.modules_sha256?.['scenarios-receiver-fault.mjs'] !== sha256(readFileSync(new URL('./scenarios-receiver-fault.mjs', import.meta.url))) || runner.modules_sha256?.['scenarios-queue-fault.mjs'] !== sha256(readFileSync(new URL('./scenarios-queue-fault.mjs', import.meta.url)))) fail('ACCEPTANCE_RECEIVER_RUNNER_PROVENANCE_MISSING', 'receiver/probe OS故障moduleの正式controller commit/hashがありません') + const own = state(s).receiverDeadline = { runner, evidenceFile: s.artifactFor(s, 'receiver-timeout-original'), cleanupFile: s.artifactFor(s, 'receiver-timeout-cleanup') } + own.fixture = await factory.open({ harness: 'codex', initialJoin: false, prepare: async fixture => { + own.fixture = fixture; ownedFixtures.add(fixture) + own.probeFault = await installQueueProbeFault(fixture, { pkg: s.pkg, artifact: label => s.artifactFor(s, label) }) + own.fault = await installReceiverPreparationFault(fixture, { pkg: s.pkg, artifact: label => s.artifactFor(s, label) }) + } }) + await own.fixture.trackOwnProcesses() + const parent = own.fixture.owner + own.fault.bindParent(parent) + await own.fixture.submit(`Peertable parent_joinをproject=${own.fixture.project} name=${own.fixture.name}で1回だけ呼び、公式結果を原文で報告してください。receiver準備の実応答期限を観測中です。自動再試行・設定変更はしないでください。`) + const pause = await s.until('正規MCP初回receiverの自己OS停止', () => own.fault.pause(), 120000, 50) + if (pause.parent.pid !== parent.pid || pause.parent.started !== parent.started) fail('ACCEPTANCE_RECEIVER_NATIVE_PARENT_MISMATCH', 'OS停止と起動済み専用親本人が一致しません') + own.pause = pause + own.file = await s.until('receiver試験の公式会話file', () => own.fixture.adapter.transcript(pause.session), 15000, 50) + const seen = own.fixture.adapter.read(own.file), turn = seen.turns.at(-1) + writeFileSync(own.evidenceFile, JSON.stringify({ runner, pause, transcript_raw: readFileSync(own.file, 'utf8'), mcp_rpc_events: own.fault.events() }, null, 2)) + return { ...s.result(s, 'own_receiver_preparation_pending', 'os_process', { parent_session: pause.session, related_session: pause.session, turn_id: turn, parent_owner: parent, mcp_owner: pause.mcp, receiver_owner: pause.owner, original_mcp_request: pause.request, original_artifact: own.evidenceFile, binding_pending_exists: false, endpoint_created: false, runner }), related_sessions: [pause.session] } + } + actions.receiver_preparation_timeout_observe = async (_, s) => { + const own = state(s).receiverDeadline + if (!own?.pause) fail('ACCEPTANCE_RECEIVER_FAULT_MISSING', '正規MCP初回receiverの自己停止証拠がありません') + const native = await s.until('同CID公式MCP receiver timeout完了', () => codexReceiverFailureBoundary({ file: own.file, session: own.pause.session, arguments: own.pause.request.row.params.arguments }), 45000, 50) + const { projectEndpoints } = await import(pathToFileURL(join(s.pkg, 'skill/scripts/parent-runtime.mjs')).href) + const endpoints = projectEndpoints(own.fixture.project).map(item => item.id), health = await factory.api(own.fixture.room)('members') + const evidence = { runner: own.runner, pause: own.pause, native, endpoints, health, mcp_rpc_events: own.fault.events(), transcript_raw: readFileSync(own.file, 'utf8') } + writeFileSync(own.evidenceFile, JSON.stringify(evidence, null, 2)) + const proof = receiverPreparationFailure({ events: evidence.mcp_rpc_events, pause: own.pause, native, endpoints, health, sameProcess: s.sameProcess }) + await own.fault.close(); own.verifiedFailure = proof + return { ...s.result(s, 'receiver_preparation_rpc_timeout', 'harness_transcript', { ...proof, parent_session: own.pause.session, related_session: own.pause.session, turn_id: native.turn_id, error_code: 'PARENT_CODEX_RPC_TIMEOUT', endpoint_created: false, original_artifact: own.evidenceFile, runner: own.runner }), related_sessions: [own.pause.session] } + } + if (['cursor', 'grok'].includes(primary.harness)) { actions.work_start = async (_, s) => { const target = self(s), fixture = fixtureFor(target) @@ -682,16 +718,20 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { if (!/^[0-9a-f]{40}$/u.test(runner?.commit ?? '') || !runner.modules_sha256?.['scenarios-queue-fault.mjs'] || runner.modules_sha256['scenarios-queue-fault.mjs'] !== sha256(readFileSync(new URL('./scenarios-queue-fault.mjs', import.meta.url)))) fail('ACCEPTANCE_QUEUE_RUNNER_PROVENANCE_MISSING', 'OS故障moduleの正式controller commit/hashがありません') const current = state(s), evidenceFile = s.artifactFor(s, 'probe-timeout-original'), cleanupFile = s.artifactFor(s, 'probe-timeout-cleanup') current.probeDeadline = { evidenceFile, cleanupFile, runner } - const fixture = await factory.open({ harness: 'codex', initialJoin: false, prepare: async fixture => { + const prepared = current.receiverDeadline + if (prepared && !prepared.verifiedFailure) fail('ACCEPTANCE_RECEIVER_RPC_FAILURE_MISSING', '同runの初回receiver期限failureが未確認です') + const fixture = prepared?.fixture ?? await factory.open({ harness: 'codex', initialJoin: false, prepare: async fixture => { current.probeDeadline.fixture = fixture ownedFixtures.add(fixture) current.probeDeadline.fault = await installQueueProbeFault(fixture, { pkg: s.pkg, artifact: label => s.artifactFor(s, label) }) } }) const own = current.probeDeadline + if (prepared) { own.fixture = fixture; own.fault = prepared.probeFault } await fixture.submit(`Peertable parent_joinをproject=${fixture.project} name=${fixture.name}で1回だけ呼び、結果を原文で報告してください。専用processの実probe期限を観測中です。自動再試行・設定変更はしないでください。`) const pause = await s.until('専用watcher初回queue接続のOS停止', () => own.fault.pause(), 120000, 50) own.pause = pause const target = await fixture.refreshTarget(null, { waitForVerified: false }) + if (prepared && target.meta.parent_session !== prepared.pause.session) fail('ACCEPTANCE_RECEIVER_REJOIN_SESSION_CHANGED', 'receiver期限failure後の正規再joinが別CIDです') if (target.spool.id !== pause.endpoint_id || target.meta.parent_session !== pause.caller.conversation || target.meta.parent_process.pid !== pause.caller.owner.pid || target.meta.parent_process.started !== pause.caller.owner.started) fail('ACCEPTANCE_QUEUE_FAULT_CALLER_MISMATCH', '停止境界と実MCP相関のfixture本人が一致しません') own.target = target s.context.registerEndpoint('probe-timeout', target) @@ -800,6 +840,7 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { const errors = [] try { const current = saved.get(s.runId) + if (current?.receiverDeadline) { await current.receiverDeadline.fault?.close(); await current.receiverDeadline.probeFault?.close() } if (current?.probeDeadline) { current.probeDeadline.stateObserver?.close(); await current.probeDeadline.fault?.close() } for (const release of current?.deadline?.releases ?? []) writeFileSync(release, '{}') if (current?.work) { writeFileSync(current.work.release, '{}'); if (current.work.owner) await waitOwnedFixtureExit({ readEndpoints: () => [], knownOwners: [current.work.owner], indexExists: () => false, sameProcess: s.sameProcess, timeout: 30000 }) } @@ -819,6 +860,15 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { } } catch (error) { errors.push(error) } try { await factory.close() } catch (error) { errors.push(error) } + const receiver = saved.get(s.runId)?.receiverDeadline + if (receiver) { + try { + const known = [receiver.pause?.parent, receiver.pause?.mcp, receiver.pause?.owner].filter(Boolean), live = known.filter(owner => s.sameProcess(owner)) + const cleanup = { at: new Date().toISOString(), runner: receiver.runner, related_session: receiver.pause?.session, known_owners: known, live_owners: live, fixture_closed: receiver.fixture?.closed === true } + writeFileSync(receiver.cleanupFile, JSON.stringify(cleanup, null, 2)) + if (live.length || !cleanup.fixture_closed) fail('ACCEPTANCE_RECEIVER_FAULT_CLEANUP_INCOMPLETE', '自己receiver試験の親/MCP/接続processが残っています') + } catch (error) { errors.push(error) } + } const probe = saved.get(s.runId)?.probeDeadline if (probe) { try { diff --git a/experiments/parent-product-acceptance/scenarios-receiver-fault.mjs b/experiments/parent-product-acceptance/scenarios-receiver-fault.mjs new file mode 100644 index 00000000..ef1d1b9a --- /dev/null +++ b/experiments/parent-product-acceptance/scenarios-receiver-fault.mjs @@ -0,0 +1,110 @@ +// Codex正規MCPの初回receiverだけを停止する。caller metadata/時計/RPC応答は変更しない。 +import { readFileSync, writeFileSync, appendFileSync, existsSync, realpathSync } from 'node:fs' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { syncBuiltinESMExports } from 'node:module' +import { isDeepStrictEqual } from 'node:util' +import cp from 'node:child_process' +import { queueFaultRpcRows } from './scenarios-queue-fault.mjs' + +const fail = (code, message) => { throw Object.assign(new Error(message), { code }) } +const same = (a, b) => a && b && a.pid === b.pid && a.started === b.started +const logRows = file => existsSync(file) ? readFileSync(file, 'utf8').split('\n').filter(Boolean).map(JSON.parse) : [] + +// data listenerを追加するとSDK起動前にstdinがflowingになる。既存emitだけを受動観測する。 +export function observeReceiverStdin(stream, record) { + const emit = stream.emit + stream.emit = function (...args) { if (args[0] === 'data') record(args[1]); return Reflect.apply(emit, this, args) } +} + +// 改行確定とUTF-8 chunk復元はqueue observerと同じ処理。channelの原bytesも返す。 +export function receiverMcpRows(events, channel) { + return queueFaultRpcRows(events.filter(event => event.kind === channel).map(event => ({ ...event, kind: channel === 'mcp_input_raw' ? 'rpc_write_raw' : 'rpc_read_raw', child_pid: event.pid }))) +} + +export function receiverPreparationOwner({ events, parent, mcp, executable, project, name, endpoints, sameProcess, processDescendsFrom }) { + const input = receiverMcpRows(events, 'mcp_input_raw').filter(event => event.pid === mcp.pid) + const initialized = input.find(event => event.row.method === 'initialize') + const joins = input.filter(event => event.row.method === 'tools/call' && event.row.params?.name === 'parent_join') + const request = joins.at(-1) + if (initialized?.row.params?.clientInfo?.name !== 'codex-mcp-client' || joins.length !== 1 || !request || !['string', 'number'].includes(typeof request.row.id) || typeof request.row.params._meta?.threadId !== 'string' || !request.row.params._meta.threadId || request.row.params.arguments?.project !== project || request.row.params.arguments?.name !== name) fail('ACCEPTANCE_RECEIVER_MCP_CALLER_UNBOUND', '初回正規MCP入力のclient/requestId/threadId/project/nameが一致しません') + if (!sameProcess(parent) || !sameProcess(mcp) || !processDescendsFrom(mcp, parent) || realpathSync(executable) !== realpathSync(parent.executable) || endpoints.length) fail('ACCEPTANCE_RECEIVER_PREPARATION_OWNER', '専用親/MCP/binary又はendpoint作成前の境界が一致しません') + return { parent, mcp, executable, project, session: request.row.params._meta.threadId, request, initialize: initialized, endpoints_before: endpoints } +} + +export function receiverPreparationFailure({ events, pause, native, endpoints, health, sameProcess }) { + const rpc = queueFaultRpcRows(events), initialized = rpc.find(event => event.pid === pause.mcp.pid && event.child_pid === pause.owner.pid && event.kind === 'rpc_write_raw' && event.row.method === 'initialize') + const output = receiverMcpRows(events, 'mcp_output_raw').find(event => event.pid === pause.mcp.pid && event.row.id === pause.request.row.id) + const error = output?.row.result?.structuredContent + const closed = events.find(event => event.kind === 'receiver_child_closed' && same(event.owner, pause.owner) && event.pid === pause.mcp.pid) + if (!initialized || rpc.some(event => event.pid === pause.mcp.pid && event.child_pid === pause.owner.pid && event.kind === 'rpc_read_raw' && event.row.id === initialized.row.id)) fail('ACCEPTANCE_RECEIVER_TIMEOUT_STAGE', 'initial receiver initializeの無応答期限ではありません') + if (error?.schema !== 'peertable.parent-error.v1' || error.state !== 'failed' || error.error_code !== 'PARENT_CODEX_RPC_TIMEOUT' || error.detail !== 'initialize' || output.row.result.isError !== true || !native || native.session !== pause.session || !isDeepStrictEqual(native.result, error) || !isDeepStrictEqual(native.arguments, pause.request.row.params.arguments)) fail('ACCEPTANCE_RECEIVER_RPC_FAILURE_MISSING', '原MCP出力と同CID公式completed itemに同じRPC期限failureがありません') + const elapsed = Date.parse(output.at) - Date.parse(initialized.at) + if (!Number.isFinite(elapsed) || elapsed < 15000 || !closed || closed.identity_alive !== false || sameProcess(pause.owner) || endpoints.length || !Array.isArray(health.members) || health.members.some(member => member.name === pause.request.row.params.arguments.name && member.delivery?.kind === 'parent_receiver') || !sameProcess(pause.parent)) fail('ACCEPTANCE_RECEIVER_PREJOIN_FAILURE_BOUNDARY', '実15秒期限/own child消失/親存命/新endpoint未作成が揃いません') + return { session: pause.session, native, original_request: pause.request, original_output: output, initialize_request: initialized, rpc_failure_elapsed_ms: elapsed, parent_owner: pause.parent, mcp_owner: pause.mcp, receiver_owner: pause.owner, closed, endpoints_after: endpoints, health } +} + +export function codexReceiverFailureBoundary({ file, session, arguments: args, after = -1 }) { + const lines = readFileSync(file, 'utf8').split('\n'); lines.pop(); let cid + for (const [order, line] of lines.entries()) { + let row + try { row = JSON.parse(line) } catch { fail('ACCEPTANCE_TRANSCRIPT_CORRUPT', 'Codex確定transcript行がJSONではありません') } + if (row.type === 'session_meta') cid = row.payload.id + const p = row.payload, item = p?.item, result = item?.result?.structuredContent + // 公式generate-json-schemaのMcpToolCallStatus。確定したerror結果だけを照合する。 + if (order > after && cid === session && row.type === 'event_msg' && p.type === 'item_completed' && p.thread_id === session && p.turn_id && item?.type === 'McpToolCall' && item.server === 'peertable_parent' && item.tool === 'parent_join' && ['completed', 'failed'].includes(item.status) && isDeepStrictEqual(item.arguments, args) && result?.schema === 'peertable.parent-error.v1' && result.state === 'failed' && result.error_code === 'PARENT_CODEX_RPC_TIMEOUT') return { session, turn_id: p.turn_id, id: item.id, order, arguments: item.arguments, result, raw: row } + } + return null +} + +export async function installReceiverPreparationFault(fixture, { pkg, artifact }) { + if (fixture.harness !== 'codex' || fixture.pty) fail('ACCEPTANCE_RECEIVER_FAULT_PREPARATION_ORDER', 'Codex専用親の起動前だけにreceiver observerを準備します') + if (process.platform === 'win32') fail('ACCEPTANCE_RECEIVER_FAULT_OS_API_UNCONFIRMED', 'Windowsのown receiver停止APIは未実測です') + const configFile = artifact('receiver-fault-config'), logFile = artifact('receiver-mcp-rpc-os.jsonl'), pauseFile = artifact('receiver-suspended'), disarmed = artifact('receiver-disarmed') + const config = { pkg, project: fixture.project, name: fixture.name, log_file: logFile, pause_file: pauseFile, disarmed_file: disarmed } + writeFileSync(configFile, JSON.stringify(config), { mode: 0o600 }) + const prior = fixture.processEnvironment ?? {} + fixture.processEnvironment = { ...prior, NODE_OPTIONS: [prior.NODE_OPTIONS ?? process.env.NODE_OPTIONS, `--import=${import.meta.url}`].filter(Boolean).join(' '), PEERTABLE_ACCEPTANCE_RECEIVER_FAULT: configFile } + const platform = await import(pathToFileURL(join(pkg, 'skill/scripts/parent-platform.mjs')).href) + return { config_file: configFile, log_file: logFile, events: () => logRows(logFile), pause: () => existsSync(pauseFile) ? JSON.parse(readFileSync(pauseFile, 'utf8')) : null, + bindParent(parent) { if (!platform.sameProcess(parent)) fail('ACCEPTANCE_RECEIVER_NATIVE_PARENT_GONE', '起動済み専用親が存命ではありません'); config.parent = parent; writeFileSync(configFile, JSON.stringify(config), { mode: 0o600 }) }, + async close() { writeFileSync(disarmed, '{}', { mode: 0o600 }); const paused = this.pause(); if (paused && platform.sameProcess(paused.owner)) { process.kill(paused.owner.pid, 'SIGCONT'); appendFileSync(logFile, JSON.stringify({ at: new Date().toISOString(), kind: 'finalize_receiver_sigcont', owner: paused.owner }) + '\n') } }, + } +} + +// own MCPだけ。watcher/別MCP/親CLIに同じmoduleが継承されても作用しない。 +const configPath = process.env.PEERTABLE_ACCEPTANCE_RECEIVER_FAULT +if (configPath && existsSync(configPath)) { + const config = JSON.parse(readFileSync(configPath, 'utf8')) + if (process.cwd() === config.project && process.argv[1] === join(config.pkg, 'room/parent-client.mjs')) { + const platform = await import(pathToFileURL(join(config.pkg, 'skill/scripts/parent-platform.mjs')).href) + const { projectEndpoints } = await import(pathToFileURL(join(config.pkg, 'skill/scripts/parent-runtime.mjs')).href) + const log = (kind, detail) => appendFileSync(config.log_file, JSON.stringify({ at: new Date().toISOString(), pid: process.pid, kind, ...detail }) + '\n', { mode: 0o600 }) + const bytes = (data, encoding) => Buffer.isBuffer(data) ? data : Buffer.from(data, typeof encoding === 'string' ? encoding : 'utf8') + observeReceiverStdin(process.stdin, chunk => log('mcp_input_raw', { raw_base64: bytes(chunk).toString('base64') })) + const output = process.stdout.write + process.stdout.write = function (...args) { log('mcp_output_raw', { raw_base64: bytes(args[0], args[1]).toString('base64') }); return Reflect.apply(output, this, args) } + const original = cp.spawn; let stopped = false + cp.spawn = function (...args) { + const [executable, argv, options] = args + if (stopped || existsSync(config.disarmed_file) || options?.cwd !== config.project || !isDeepStrictEqual(argv, ['app-server', '--listen', 'stdio://'])) return Reflect.apply(original, this, args) + const active = JSON.parse(readFileSync(configPath, 'utf8')), mcp = platform.processIdentity(process.pid), actual = platform.harnessProcess('codex') + if (!same(active.parent, actual)) fail('ACCEPTANCE_RECEIVER_NATIVE_PARENT_MISMATCH', '公式MCP祖先が指定fixtureの親本人ではありません') + const proof = receiverPreparationOwner({ events: logRows(config.log_file), parent: actual, mcp, executable, project: config.project, name: config.name, endpoints: projectEndpoints(config.project).map(item => item.id), ...platform }) + const child = Reflect.apply(original, this, args) + process.kill(child.pid, 'SIGSTOP'); stopped = true + const owner = platform.processIdentity(child.pid) + if (!owner || owner.parent !== mcp.pid || !platform.processDescendsFrom(owner, mcp) || !platform.sameProcess(owner) || realpathSync(owner.executable) !== realpathSync(executable)) { process.kill(child.pid, 'SIGCONT'); fail('ACCEPTANCE_RECEIVER_CHILD_IDENTITY', '停止したinitial receiverの本人identityが一致しません') } + const pause = { ...proof, owner, spawn_argv: argv, spawn_cwd: options.cwd, paused_at: new Date().toISOString() } + writeFileSync(config.pause_file, JSON.stringify(pause), { mode: 0o600 }); log('receiver_child_sigstop', pause) + const write = child.stdin.write + child.stdin.write = function (...args) { log('rpc_write_raw', { child_pid: child.pid, raw_base64: bytes(args[0], args[1]).toString('base64') }); return Reflect.apply(write, this, args) } + child.stdout.on('data', chunk => log('rpc_read_raw', { child_pid: child.pid, raw_base64: bytes(chunk).toString('base64') })) + child.on('close', (code, signal) => log('receiver_child_closed', { owner, code, signal, identity_alive: Boolean(platform.sameProcess(owner)) })) + // 実RPCの15秒期限とcloseの2秒制御を先に通す。停止中のSIGTERM保留も自己本人だけを解除する。 + setTimeout(() => { const alive = platform.sameProcess(owner); if (alive) process.kill(owner.pid, 'SIGCONT'); log('receiver_fault_release', { owner, still_same: Boolean(alive) }) }, 18000).unref() + return child + } + syncBuiltinESMExports() + } +} diff --git a/experiments/parent-product-acceptance/scenarios-receiver-fault.test.mjs b/experiments/parent-product-acceptance/scenarios-receiver-fault.test.mjs new file mode 100644 index 00000000..4f3d33cb --- /dev/null +++ b/experiments/parent-product-acceptance/scenarios-receiver-fault.test.mjs @@ -0,0 +1,87 @@ +// caller/RPC期限の境界だけをfocusedで確認する。fixture成績を実親の合格へ数えない。 +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' +import { receiverMcpRows, receiverPreparationOwner, receiverPreparationFailure, codexReceiverFailureBoundary, installReceiverPreparationFault, observeReceiverStdin } from './scenarios-receiver-fault.mjs' + +const at = n => new Date(n).toISOString() +const raw = (kind, row, time = 0, pid = 20, child_pid) => ({ kind, pid, child_pid, at: at(time), raw_base64: Buffer.from(JSON.stringify(row) + '\n').toString('base64') }) +const args = { project: '/own', name: '親' } +const initialize = { id: 1, method: 'initialize', params: { clientInfo: { name: 'codex-mcp-client' } } } +const request = { id: 2, method: 'tools/call', params: { name: 'parent_join', arguments: args, _meta: { threadId: 'CID' } } } +const parent = { pid: 10, started: 'parent-start', executable: process.execPath }, mcp = { pid: 20, started: 'mcp-start' }, owner = { pid: 21, started: 'child-start' } +const events = () => [raw('mcp_input_raw', initialize), raw('mcp_input_raw', request)] +const alive = identity => identity?.pid === parent.pid || identity?.pid === mcp.pid + +test('受動MCP原bytesは改行/UTF-8 chunkを復元し、他方向/他processを合算しない', () => { + const bytes = Buffer.from(JSON.stringify(request) + '\n'), split = bytes.indexOf(Buffer.from('親')) + 1 + const input = [bytes.subarray(0, split), bytes.subarray(split)].map(part => ({ pid: 20, kind: 'mcp_input_raw', raw_base64: part.toString('base64') })) + assert.deepEqual(receiverMcpRows(input, 'mcp_input_raw')[0].row, request) + assert.equal(receiverMcpRows(input, 'mcp_output_raw').length, 0) + input[1].pid = 30 + assert.throws(() => receiverMcpRows(input, 'mcp_input_raw'), { code: 'ACCEPTANCE_QUEUE_FAULT_RPC_UTF8_INVALID' }) +}) + +test('stdin受動観測はSDK開始までflowingにせず、既存data/戻り値を保持する', async () => { + const stream = new PassThrough(), observed = [], consumed = [] + observeReceiverStdin(stream, chunk => observed.push(chunk)) + assert.equal(stream.readableFlowing, null) + stream.write(Buffer.from('原<&>\r\n')) + assert.equal(observed.length, 0) + const received = new Promise(resolve => stream.once('data', resolve)) + stream.on('data', chunk => consumed.push(chunk)) + await received + assert.equal(observed.length, 1); assert.ok(observed[0].equals(consumed[0])); assert.equal(consumed[0].toString(), '原<&>\r\n') + assert.equal(stream.emit('unhandled-event'), false) + stream.destroy() +}) + +test('initial receiverの所有は公式client/meta入力と実親/MCP/binary/endpoint未作成へ限定する', () => { + const value = { events: events(), parent, mcp, executable: process.execPath, project: args.project, name: args.name, endpoints: [], sameProcess: alive, processDescendsFrom: (a, b) => a.pid === mcp.pid && b.pid === parent.pid } + assert.equal(receiverPreparationOwner(value).session, 'CID') + assert.throws(() => receiverPreparationOwner({ ...value, endpoints: ['existing'] }), { code: 'ACCEPTANCE_RECEIVER_PREPARATION_OWNER' }) + assert.throws(() => receiverPreparationOwner({ ...value, processDescendsFrom: () => false }), { code: 'ACCEPTANCE_RECEIVER_PREPARATION_OWNER' }) + assert.throws(() => receiverPreparationOwner({ ...value, events: [...events(), raw('mcp_input_raw', request)] }), { code: 'ACCEPTANCE_RECEIVER_MCP_CALLER_UNBOUND' }) + for (const mutate of [r => { r.params._meta = {} }, r => { r.params.arguments.name = '他親' }, r => { r.params._meta.threadId = {} }]) { + const changed = structuredClone(request); mutate(changed) + assert.throws(() => receiverPreparationOwner({ ...value, events: [raw('mcp_input_raw', initialize), raw('mcp_input_raw', changed)] }), { code: 'ACCEPTANCE_RECEIVER_MCP_CALLER_UNBOUND' }) + } +}) + +test('receiver期限failureは15秒原RPCと公式MCP出力/同CID item/自然消失/endpoint無しを要求する', () => { + const error = { schema: 'peertable.parent-error.v1', state: 'failed', error_code: 'PARENT_CODEX_RPC_TIMEOUT', detail: 'initialize' } + const pause = { parent, mcp, owner, session: 'CID', request: { row: request } }, native = { session: 'CID', arguments: args, result: error, turn_id: 'turn' } + const timeline = [raw('rpc_write_raw', { id: 1, method: 'initialize' }, 100, 20, 21), raw('mcp_output_raw', { id: 2, result: { structuredContent: error, isError: true } }, 17100), { kind: 'receiver_child_closed', pid: 20, owner, identity_alive: false }] + const value = { events: timeline, pause, native, endpoints: [], health: { members: [] }, sameProcess: alive } + assert.equal(receiverPreparationFailure(value).rpc_failure_elapsed_ms, 17000) + const reject = (mutate, code) => { const changed = structuredClone({ ...value, sameProcess: undefined }); changed.sameProcess = alive; mutate(changed); assert.throws(() => receiverPreparationFailure(changed), { code }) } + reject(v => { v.native.session = '他CID' }, 'ACCEPTANCE_RECEIVER_RPC_FAILURE_MISSING') + reject(v => { v.events[1].at = at(15099) }, 'ACCEPTANCE_RECEIVER_PREJOIN_FAILURE_BOUNDARY') + reject(v => { v.endpoints = ['new-endpoint'] }, 'ACCEPTANCE_RECEIVER_PREJOIN_FAILURE_BOUNDARY') + reject(v => { v.health = {} }, 'ACCEPTANCE_RECEIVER_PREJOIN_FAILURE_BOUNDARY') + reject(v => { v.events[2].owner = { ...v.events[2].owner, started: '再利用PID' } }, 'ACCEPTANCE_RECEIVER_PREJOIN_FAILURE_BOUNDARY') + reject(v => { v.events.push(raw('rpc_read_raw', { id: 1, result: {} }, 200, 20, 21)) }, 'ACCEPTANCE_RECEIVER_TIMEOUT_STAGE') + const renamed = structuredClone(error); renamed.error_code = 'PARENT_BIND_TIMEOUT' + assert.throws(() => receiverPreparationFailure({ ...value, native: { ...native, result: renamed } }), { code: 'ACCEPTANCE_RECEIVER_RPC_FAILURE_MISSING' }) +}) + +test('公式completed MCP failureは原transcriptの同CID/全入力を要求し自由文で代用しない', t => { + const dir = mkdtempSync(join(tmpdir(), 'pt-receiver-native-')); t.after(() => rmSync(dir, { recursive: true, force: true })) + const file = join(dir, 'rollout.jsonl'), error = { schema: 'peertable.parent-error.v1', state: 'failed', error_code: 'PARENT_CODEX_RPC_TIMEOUT', detail: 'initialize' } + const rows = [{ type: 'session_meta', payload: { id: 'CID' } }, { type: 'event_msg', payload: { type: 'item_completed', thread_id: 'CID', turn_id: 'turn', item: { type: 'McpToolCall', id: 'native-id', server: 'peertable_parent', tool: 'parent_join', status: 'completed', arguments: args, result: { structuredContent: error } } } }] + const save = () => writeFileSync(file, rows.map(JSON.stringify).join('\n') + '\n') + save(); assert.equal(codexReceiverFailureBoundary({ file, session: 'CID', arguments: args }).id, 'native-id') + rows[1].payload.item.status = 'failed'; save(); assert.equal(codexReceiverFailureBoundary({ file, session: 'CID', arguments: args }).result.error_code, 'PARENT_CODEX_RPC_TIMEOUT') + rows[1].payload.item.status = 'inProgress'; save(); assert.equal(codexReceiverFailureBoundary({ file, session: 'CID', arguments: args }), null) + rows[1].payload.item.status = 'completed'; save() + assert.equal(codexReceiverFailureBoundary({ file, session: 'CID', arguments: { ...args, name: '他親' } }), null) + rows[0].payload.id = '別CID'; save(); assert.equal(codexReceiverFailureBoundary({ file, session: 'CID', arguments: args }), null) +}) + +test('receiver OS observerは専用親起動前だけ、Windows未実測はtyped未達にする', async () => { + await assert.rejects(installReceiverPreparationFault({ harness: 'codex', pty: 'live' }, {}), { code: 'ACCEPTANCE_RECEIVER_FAULT_PREPARATION_ORDER' }) + if (process.platform === 'win32') await assert.rejects(installReceiverPreparationFault({ harness: 'codex' }, {}), { code: 'ACCEPTANCE_RECEIVER_FAULT_OS_API_UNCONFIRMED' }) +}) diff --git a/experiments/parent-product-acceptance/scenarios.mjs b/experiments/parent-product-acceptance/scenarios.mjs index f1e81203..0790ba3e 100644 --- a/experiments/parent-product-acceptance/scenarios.mjs +++ b/experiments/parent-product-acceptance/scenarios.mjs @@ -40,7 +40,7 @@ export function scenarioPlan(name, { pageChars, harness = 'claude' } = {}) { compatibility_hooks: [native('compatibility_arm', 'own_compatible_hook_loaded'), send('互換hook'), native('compatibility_observe', 'one_adapter_one_binding')], background_end: [native('background_cancel', 'native_task_cancel_observed'), native('background_timeout', 'native_task_timeout_observed'), native('background_exit', 'native_task_exit_observed'), native('background_observe', 'task_end_not_delivered'), native('receiver_rearm', 'native_receiving_rearmed'), send('背景終了後')], lease: [native('lease_expiry_arm', 'own_finite_lease'), send('期限前'), native('lease_expiry_observe', 'finite_lease_control_not_body'), send('再武装待ち', { defer: true }), native('receiver_rearm', 'same_endpoint_cursor_rearmed'), native('pending_recover', 'native_delivery'), send('期限更新後')], - binding_deadline: [native('binding_timeout_arm', 'own_binding_pending'), native('binding_timeout_observe', 'binding_timeout_typed_failed'), native('probe_timeout_arm', 'own_probe_not_consumed'), native('probe_timeout_observe', 'probe_timeout_typed_failed'), native('deadline_restore', 'new_join_verified'), send('期限障害復旧後')], + binding_deadline: [...(harness === 'codex' ? [native('receiver_preparation_timeout_arm', 'own_receiver_preparation_pending'), native('receiver_preparation_timeout_observe', 'receiver_preparation_rpc_timeout')] : [native('binding_timeout_arm', 'own_binding_pending'), native('binding_timeout_observe', 'binding_timeout_typed_failed')]), native('probe_timeout_arm', 'own_probe_not_consumed'), native('probe_timeout_observe', 'probe_timeout_typed_failed'), native('deadline_restore', 'new_join_verified'), send('期限障害復旧後')], slot_race: [native('slot_race_arm', 'own_simultaneous_hooks'), send('slot競合'), native('slot_race_observe', 'single_live_slot'), send('slot継続')], lifecycle: [send('更新前'), native('same_version_update', 'same_version_single_registration'), native('new_version_update', 'new_version_history_binding_preserved'), send('更新後'), native('teardown', 'receiver_stopped_parent_alive_history_kept'), native('lifecycle_rejoin', 'new_binding_after_teardown'), send('再導入後')], package: [native('package_inspect', 'pack_installed_no_checkout_paths'), send('導入物配送'), native('package_observe', 'pack_native_entry_used')], @@ -92,13 +92,14 @@ export async function runScenario(name, context) { if (nonces.has(result.check.nonce) || result.check.nonce !== nonce) fail('ACCEPTANCE_SCENARIO_EVIDENCE_REUSED', `${name}: 専用の投稿ではありません`) nonces.add(nonce); checks.push(result.check) } - observations.push(...validateBoundary(result, { expectation: item.expectation, session, runId, scenario: name })) + const boundaryRows = validateBoundary(result, { expectation: item.expectation, session, runId, scenario: name }) + observations.push(...boundaryRows) if (result.checks) for (const check of result.checks) { validateNativeCheck(check, { scenario: name, runId, session: check.parent_session ?? session }) if (nonces.has(check.nonce)) fail('ACCEPTANCE_SCENARIO_EVIDENCE_REUSED', `${name}: 同じ観測を再使用しています`) nonces.add(check.nonce); checks.push(check) } - trace.push({ step: index, action: item.action, expectation: item.expectation, artifacts: result.observations.map(row => row.artifact), ...(result.related_sessions ? { related_sessions: result.related_sessions } : {}) }) + trace.push({ step: index, action: item.action, expectation: item.expectation, artifacts: boundaryRows.map(row => row.artifact), related_sessions: [...new Set(boundaryRows.filter(row => row.parent_session !== session).map(row => row.parent_session))], observations: boundaryRows.map(({ kind, parent_session, turn_id, artifact }) => ({ kind, parent_session, turn_id, artifact })) }) } if (!checks.length) fail('ACCEPTANCE_NO_NATIVE_DELIVERY', `${name}: 専用本文の実親受信がありません`) if (['consecutive', 'source_reconnect', 'burst'].includes(name) && checks.some((check, index) => index && (check.original.seq <= checks[index - 1].original.seq || check.delivery.order <= checks[index - 1].delivery.order))) fail('ACCEPTANCE_SEQ_ORDER', `${name}: seq順に配送されていません`) diff --git a/experiments/parent-product-acceptance/scenarios.test.mjs b/experiments/parent-product-acceptance/scenarios.test.mjs index af0ec7e3..61c8f76b 100644 --- a/experiments/parent-product-acceptance/scenarios.test.mjs +++ b/experiments/parent-product-acceptance/scenarios.test.mjs @@ -25,6 +25,29 @@ test('未知scenarioと導入物のpage上限不足をtyped failureにする', ( assert.throws(() => scenarioPlan('fake'), { code: 'ACCEPTANCE_SCENARIO_UNKNOWN' }) assert.throws(() => scenarioPlan('original'), { code: 'ACCEPTANCE_PAGE_LIMIT_MISSING' }) }) +test('束縛期限caseはCodexの実receiver準備期限へ分岐し後半4stepを全harnessで保持する', () => { + const codex = scenarioPlan('binding_deadline', { harness: 'codex' }) + assert.deepEqual(codex.slice(0, 2).map(step => [step.action, step.expectation]), [['receiver_preparation_timeout_arm', 'own_receiver_preparation_pending'], ['receiver_preparation_timeout_observe', 'receiver_preparation_rpc_timeout']]) + for (const harness of ['claude', 'cursor', 'grok']) { + const plan = scenarioPlan('binding_deadline', { harness }) + assert.deepEqual(plan.slice(0, 2).map(step => step.expectation), ['own_binding_pending', 'binding_timeout_typed_failed']) + assert.deepEqual(plan.slice(2), codex.slice(2)) + } +}) +test('traceは各stepで検証したartifact/CID/turnを保持し、未使用関連CIDを許可へ流用しない', async () => { + const actions = {}, plan = scenarioPlan('package'), primary = 'session', related = '専用CID' + for (const [index, step] of plan.entries()) actions[step.action] = async (input, scope) => { + const row = { kind: step.expectation, run_id: scope.runId, scenario: scope.scenario, parent_session: index === 0 ? related : primary, turn_id: `turn-${index}`, artifact: `/artifact-${index}`, source: 'harness_transcript' } + const result = { expectation: step.expectation, verified: true, observations: [row], related_sessions: index === 0 ? [related, '未使用CID'] : [] } + if (step.action === 'deliver') { const item = check(); item.nonce = input.nonce; item.reply.text = input.nonce; result.check = item } + return result + } + const measured = await runScenario('package', { harness: 'claude', session: primary, runId: 'run', pageChars: 1234, actions }) + assert.equal(measured.page_chars, 1234) + assert.deepEqual(measured.trace[0].related_sessions, [related]) + assert.deepEqual(measured.trace[0].observations, [{ kind: plan[0].expectation, parent_session: related, turn_id: 'turn-0', artifact: '/artifact-0' }]) + assert.deepEqual(measured.trace[1].related_sessions, []) +}) test('adapter未接続時は操作前に停止し、成績を作らない', async () => { let calls = 0 await assert.rejects(runScenario('busy', { harness: 'codex', session: 'session', pageChars: 100, actions: { deliver: () => { calls++ } } }), { code: 'ACCEPTANCE_SCENARIO_ADAPTER_MISSING' }) From 9eb88312df9cd6702ffb1b8e234fb0db0325b499 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:35:00 +0900 Subject: [PATCH 69/72] =?UTF-8?q?Cursor=E8=A6=AA=E5=8F=97=E4=BF=A1?= =?UTF-8?q?=E3=81=AE=E5=AE=8C=E6=88=90=E6=B8=88=E3=81=BF=E5=85=A5=E5=8A=9B?= =?UTF-8?q?=E3=82=92=E3=83=A2=E3=83=87=E3=83=ABAPI=E3=81=A8hook=E3=81=A7?= =?UTF-8?q?=E5=88=86=E9=9B=A2=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shellへworking_directoryと背景待機指定を渡し、公式hookの入力を実taskとは別に保存する。Windows排他の実CIM競合と入力差の原記録を保持する。 --- docs/plan_parent-native-delivery.md | 4 + .../cursor-native-input-diagnosis.json | 41 ++++++++++ .../release-candidate-checkpoint.json | 10 ++- .../windows-lock-contention-diagnosis.json | 77 +++++++++++++++++++ scripts/run-product-ci.mjs | 2 +- .../scripts/parent-background-input.test.mjs | 57 ++++++++++++++ skill/scripts/parent-receivers/background.mjs | 11 ++- 7 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 rag/parent-delivery/cursor-native-input-diagnosis.json create mode 100644 rag/parent-delivery/windows-lock-contention-diagnosis.json create mode 100644 skill/scripts/parent-background-input.test.mjs diff --git a/docs/plan_parent-native-delivery.md b/docs/plan_parent-native-delivery.md index 25c041e6..dc696864 100644 --- a/docs/plan_parent-native-delivery.md +++ b/docs/plan_parent-native-delivery.md @@ -407,3 +407,7 @@ controllerはCursorの公式postToolUseが確定した入力全体を照合す Codexの`binding_deadline`前半は、[公式callerの比較](../rag/parent-delivery/codex-caller-deadline-comparison.json)に従い、receiver準備の実RPC期限を測る。AitermとPeertableのCodexは公式MCPの会話IDを直接取得するため、事前hook記録の30秒待ちは存在しない。非Codexの束縛期限と、全harnessのprobe期限・正規再登録・復旧後DMを維持する。受入gateは24scenarioの手順・順序・run・会話・artifactを正本の手順と照合し、専用会話は該当stepの宣言と観測がそろった場合だけ相関する。各scenarioの実機成立は引き続き未判定である。 旧leaseの「wrapper終了」はcontrollerの後片付け完了ではなかった。[停止記録の監査](../rag/parent-delivery/withdrawn-lease-stop-audit.json)で、旧入口には中断handlerがなく、summaryが実行中のまま残ることを確認した。旧Mac/Linux Claudeの既知processはrootの読取確認でも存命0だが、旧runを正常終了やlease合格にしない。新lease開始前に中断時の後片付けと失敗記録を修理する。 + +[Cursorの入力診断](../rag/parent-delivery/cursor-native-input-diagnosis.json)で、完成済みreceiptがhook向けの`cwd`をモデルAPIへ渡し、モデルが`working_directory`と背景待機引数へ補正する欠陥を確認した。モデル向け入力を製品で完成させ、実hook入力を別保存する。controllerでもモデル入力とhook入力を個別照合してから新sourceを実機測定する。Desktopへ同じschemaが通ることはまだ確認していない。 + +[Windowsの排他再現](../rag/parent-delivery/windows-lock-contention-diagnosis.json)では6実processの同時更新で実10秒期限の失敗が1件発生し、失敗workerの待ち時間の99.1%がCIM本人確認だった。本人確認を標準OS APIへ適合する案を検証する。期限と共通Bakery排他は維持する。設定のうち`cli-config.json`は比較元bytesが無く、変更keyと復元を確認できなかった。正式合格に数えず、この限界を保持する。 diff --git a/rag/parent-delivery/cursor-native-input-diagnosis.json b/rag/parent-delivery/cursor-native-input-diagnosis.json new file mode 100644 index 00000000..26235865 --- /dev/null +++ b/rag/parent-delivery/cursor-native-input-diagnosis.json @@ -0,0 +1,41 @@ +{ + "schema": "peertable.cursor-native-input-diagnosis.v1", + "product_source": "980cbebc1acab7461daa064abec8814b410f0137", + "formal_acceptance_credit": 0, + "root_raw_audit": true, + "observed_surface": "Windows Cursor通常CLI", + "other_surface_status": "macOS CLIの子観測一覧は保持。Linux CLIとDesktopは未検証。", + "model_input_keys": [ + "block_until_ms", + "command", + "description", + "working_directory" + ], + "model_block_until_ms": 0, + "hook_input_keys": [ + "command", + "cwd" + ], + "defect": "製品はhook入力のcwdをモデルAPIへ返していた。モデルがworking_directoryへ書き換え、背景待機引数を補っていた。完成済み入力の契約を満たしていない。", + "repair": "モデル向けShell入力にworking_directory・block_until_ms:0・descriptionを含め、実hook入力をnative_task.hook_inputへ別保存する。実task ID・PID-start・子process相関を維持する。", + "acceptance_followup": "controllerはモデル入力全体と保存された実hook入力を別々に照合する。新sourceで実機再測定する。", + "official_source": { + "url": "https://prod.cursor.com/docs/hooks", + "retrieved_at": "2026-09-29", + "claim": "公開preToolUse例のShell入力にはworking_directoryがある。公開例とCLIで観測されたhookのcwdは別形。" + }, + "limitations": [ + "モデル向けschema全文は原記録にもCLI bundleにも保存されていない。block_until_ms:0は実呼出しの観測でありbundle protobufのShellArgsの仕様として断定しない。", + "Desktopへ同じ形が通ることは未検証。正式受入を省略しない。" + ], + "fingerprints": { + "native_transcript": "f360c64dda1eec67d783dcc841e9791ab62f3e15f8788cca9bca5b28b7f6c14f", + "official_hook": "40265a1aa0865d39fdd9f1620a18cebe2636a17c89dfaf878aa8d2a4c80d18c3", + "child_observation_inventory": "a2ddbabfb8f6ab652c48b641f2b44bb0a864f0df407ea4ae5962c50a039a4d10" + }, + "focused_verification": { + "passed": 4, + "failed": 0, + "product_live": false + } +} diff --git a/rag/parent-delivery/release-candidate-checkpoint.json b/rag/parent-delivery/release-candidate-checkpoint.json index 58d25b33..44d37438 100644 --- a/rag/parent-delivery/release-candidate-checkpoint.json +++ b/rag/parent-delivery/release-candidate-checkpoint.json @@ -3,7 +3,7 @@ "source_commit": "980cbebc1acab7461daa064abec8814b410f0137", "runtime_digest": "19cceff45965f64a0733b9bc8f207fca58a2295b8d0c54ad1e0deb72f573c5b1", "package_version": "0.8.63", - "candidate_state": "diagnostic_candidate_windows_cursor_lock_pending", + "candidate_state": "diagnostic_candidate_native_input_and_windows_identity_repair_pending", "source_frozen": false, "previous_candidate_checkpoint": "rag/parent-delivery/snapshots/c97b431/release-candidate-checkpoint.json", "product_ci": { @@ -167,6 +167,14 @@ "codex_rejoin": { "state": "diagnostic_recovered_new_adapter_live_pending", "evidence": "rag/parent-delivery/codex-probe-rejoin-repair-diagnosis.json" + }, + "cursor_native_input": { + "state": "focused_passed_controller_and_native_verification_pending", + "evidence": "rag/parent-delivery/cursor-native-input-diagnosis.json" + }, + "windows_lock_contention": { + "state": "real_timeout_reproduced_native_identity_adapter_diagnosis_pending", + "evidence": "rag/parent-delivery/windows-lock-contention-diagnosis.json" } } } diff --git a/rag/parent-delivery/windows-lock-contention-diagnosis.json b/rag/parent-delivery/windows-lock-contention-diagnosis.json new file mode 100644 index 00000000..2d9cd59c --- /dev/null +++ b/rag/parent-delivery/windows-lock-contention-diagnosis.json @@ -0,0 +1,77 @@ +{ + "schema": "peertable.windows-lock-contention-diagnosis.v1", + "source_commit": "980cbebc1acab7461daa064abec8814b410f0137", + "runtime_digest": "19cceff45965f64a0733b9bc8f207fca58a2295b8d0c54ad1e0deb72f573c5b1", + "formal_acceptance_credit": 0, + "root_raw_audit": true, + "native_os": "win32", + "condition": "未改変の配布物ParentSpool.transactへ所有dummyの6実processが同時刻に入る。CIM呼出しは受動記録し結果・時計・10秒期限を変えない。", + "runs": [ + { + "n": 2, + "max_total_ms": 2689, + "failures": 0, + "cim_calls": 9 + }, + { + "n": 3, + "max_total_ms": 3224, + "failures": 0, + "cim_calls": 18 + }, + { + "n": 4, + "max_total_ms": 737, + "failures": 0, + "cim_calls": 4 + }, + { + "n": 4, + "max_total_ms": 692, + "failures": 0, + "cim_calls": 4 + }, + { + "n": 4, + "max_total_ms": 724, + "failures": 0, + "cim_calls": 4 + }, + { + "n": 5, + "max_total_ms": 747, + "failures": 0, + "cim_calls": 5 + }, + { + "n": 6, + "max_total_ms": 11230, + "failures": 1, + "cim_calls": 110 + } + ], + "failure": { + "pid": 65964, + "started": "2026-09-28T22:24:43.9314500Z", + "code": "PARENT_SPOOL_LOCK_TIMEOUT", + "total_ms": 10883.9955, + "cim_calls": 21, + "cim_ms": 10789.2552, + "cim_share": 0.9912954484407862 + }, + "confirmed_cause": "ticketを毎回読む際のsameProcessが他PIDごとにCIMを同期起動し、重なったticketの本人確認だけで排他期限を超えている。", + "repair_status": "標準OS APIで同じPID-startを確認する案を所有dummyで検証中。排他algorithm・期限・永続identityを維持する。", + "limitations": [ + "旧84の失敗rawにはticket/CIM記録がないため、同一原因とは断定しない。", + "6process同時投入は制御した再現条件で、実運用の発生頻度は未測定。", + "pwsh/CIMの時間は対象端末の実測値。", + "Windows cli-config.jsonの前後hashは不一致で、比較元bytesが保存されていないため変更keyと元への復元は確認できなかった。CLI自身の更新は時刻一致からの推測。mcp/hooks/config.tomlの前後hash一致とは区別する。" + ], + "private_raw": "/private/tmp/claude-501/qa-cursor-grok/diag-980cbeb/raw/repro", + "fingerprints": { + "data/summary.json": "5e54aaba96623619e567467687a5a2a54934a574a87a31a04a6515ca3fbe0960", + "data/run-07-n6/tickets.jsonl": "ac4a959bb9bd5609f0a5fd75e3dbd95541ae99ad0c22e157fc776fdbeaf0f287", + "data/run-07-n6/workers.jsonl": "086c8348c062a1182e00a16a82623fc49c2c0db3c4ea39893a119e0e6e8b6d43", + "analysis.json": "00636d9bbe758d8ae58a775904dc5bdbc761e5d7da84cde99616164a16eb62e9" + } +} diff --git a/scripts/run-product-ci.mjs b/scripts/run-product-ci.mjs index 2ffbf865..5bdccf70 100644 --- a/scripts/run-product-ci.mjs +++ b/scripts/run-product-ci.mjs @@ -30,7 +30,7 @@ for (const directory of ['room', 'skill/scripts', 'scripts']) { run(process.execPath, ['--test', 'skill/scripts/runtime-contract.test.mjs']) run(process.execPath, ['experiments/aiterm-unified-delivery-repro.mjs']) run(process.execPath, ['--test', 'scripts/ci-contract.test.mjs', 'scripts/docs-contract.test.mjs']) -run(process.execPath, ['--test', 'skill/scripts/parent-delivery.test.mjs', 'skill/scripts/parent-runtime-probe.test.mjs', 'skill/scripts/parent-source-runtime.test.mjs', 'skill/scripts/parent-process.test.mjs', 'skill/scripts/parent-platform-atomic.test.mjs', 'skill/scripts/parent-platform-unlinked-executable.test.mjs', 'room/parent-client.test.mjs', 'scripts/parent-delivery-acceptance.test.mjs']) +run(process.execPath, ['--test', 'skill/scripts/parent-delivery.test.mjs', 'skill/scripts/parent-background-input.test.mjs', 'skill/scripts/parent-runtime-probe.test.mjs', 'skill/scripts/parent-source-runtime.test.mjs', 'skill/scripts/parent-process.test.mjs', 'skill/scripts/parent-platform-atomic.test.mjs', 'skill/scripts/parent-platform-unlinked-executable.test.mjs', 'room/parent-client.test.mjs', 'scripts/parent-delivery-acceptance.test.mjs']) run(process.execPath, ['experiments/delivery-receipt-repro.mjs']) run(process.execPath, ['experiments/http-utf8-body-repro.mjs']) run(process.execPath, ['experiments/windows-seat-mux-repro.mjs']) diff --git a/skill/scripts/parent-background-input.test.mjs b/skill/scripts/parent-background-input.test.mjs new file mode 100644 index 00000000..da0a0057 --- /dev/null +++ b/skill/scripts/parent-background-input.test.mjs @@ -0,0 +1,57 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { ParentSpool } from './parent-delivery.mjs' +import { processIdentity } from './parent-platform.mjs' +import { waitReceipt, bindNativeWait } from './parent-receivers/background.mjs' + +function fixture(t, harness = 'cursor') { + const project = mkdtempSync(join(tmpdir(), 'peertable native input 日本語 ')) + t.after(() => rmSync(project, { recursive: true, force: true })) + const spool = ParentSpool.create(project, { harness, caller: { owner: processIdentity(process.pid) } }) + spool.publishHealth = async () => {} + return spool +} + +test('Cursor receiptはhookのcwdでなくShell APIへ完成済み背景入力を渡す', t => { + const spool = fixture(t), receipt = waitReceipt(spool) + assert.deepEqual(receipt.native_tool, { name: 'Shell', input: { + command: receipt.native_tool.input.command, working_directory: spool.project, + block_until_ms: 0, description: 'Peertable親宛受信', + } }) + assert.equal(Object.hasOwn(receipt.native_tool.input, 'cwd'), false) +}) + +test('Grok receiptは公開背景commandの入力を維持する', t => { + const receipt = waitReceipt(fixture(t, 'grok')) + assert.deepEqual(receipt.native_tool, { name: 'run_terminal_command', input: { + command: receipt.native_tool.input.command, description: 'Peertable親宛受信', background: true, timeout: 0, + } }) +}) + +test('Cursor実hookのcwdと公式working_directoryをモデル入力と分けて実PIDへ束縛する', async t => { + for (const directory of ['cwd', 'working_directory']) { + const spool = fixture(t), receipt = waitReceipt(spool) + spool.slot('cursor_background', receipt) + const hookInput = { command: receipt.native_tool.input.command, [directory]: spool.project } + assert.equal(await bindNativeWait(spool, { tool_name: 'Shell', tool_input: hookInput, tool_output: { shell_id: 'owned-task', pid: process.pid } }, 'cursor'), true) + const saved = spool.read() + assert.deepEqual(saved.waiter.native_task.input, receipt.native_tool) + assert.deepEqual(saved.waiter.native_task.hook_input, hookInput) + assert.equal(saved.runtime, 'armed') + assert.equal(saved.waiter.native_task.process_identity.started, processIdentity(process.pid).started) + } +}) + +test('同じcommandでもhookの作業directoryが欠落・不一致ならarmedへ進めない', async t => { + const spool = fixture(t), receipt = waitReceipt(spool) + spool.slot('cursor_background', receipt) + for (const fields of [{}, { cwd: '別project' }, { working_directory: '別project' }, { cwd: spool.project, working_directory: '別project' }]) { + await assert.rejects(bindNativeWait(spool, { tool_name: 'Shell', tool_input: { command: receipt.native_tool.input.command, ...fields }, tool_output: { shell_id: 'owned-task', pid: process.pid } }, 'cursor'), { code: 'PARENT_NATIVE_WAIT_INPUT_MISMATCH' }) + assert.equal(spool.read().runtime, 'rearm_pending') + assert.equal(spool.read().waiter.native_task, null) + } + assert.equal(await bindNativeWait(spool, { tool_name: 'Shell', tool_input: { command: '無関係の作業', cwd: spool.project } }, 'cursor'), false) +}) diff --git a/skill/scripts/parent-receivers/background.mjs b/skill/scripts/parent-receivers/background.mjs index c8ae422d..6369bcae 100644 --- a/skill/scripts/parent-receivers/background.mjs +++ b/skill/scripts/parent-receivers/background.mjs @@ -18,8 +18,8 @@ export function waitReceipt(spool) { const command = process.platform === 'win32' ? hookCommand(receipt.executable, receipt.args) : shellCommand(receipt.executable, receipt.args) receipt.native_tool = state.harness === 'grok' ? { name: 'run_terminal_command', input: { command, description: 'Peertable親宛受信', background: true, timeout: 0 } } - // 通常Cursor CLIの実測input。Shellは長い待機を公式の背景taskへ移す。 - : { name: 'Shell', input: { command, cwd: spool.project } } + // モデル向けShell入力。hookが返すcwdはモデルAPIの引数ではない。 + : { name: 'Shell', input: { command, working_directory: spool.project, block_until_ms: 0, description: 'Peertable親宛受信' } } state.wait_receipt = receipt return receipt }) @@ -31,6 +31,11 @@ export async function bindNativeWait(spool, event, harness) { const tool = harness === 'grok' ? event.toolName : event.tool_name const input = harness === 'grok' ? event.toolInput : event.tool_input if (tool !== expected.name || input?.command !== expected.input.command) return false + if (harness === 'cursor') { + // CLI実測のcwdと公式hook仕様のworking_directoryを明示的に照合する。 + const directories = ['cwd', 'working_directory'].filter(key => Object.hasOwn(input, key)) + if (!directories.length || directories.some(key => input[key] !== expected.input.working_directory)) throw failure('PARENT_NATIVE_WAIT_INPUT_MISMATCH') + } let output = harness === 'grok' ? event.toolResult : event.tool_output if (typeof output === 'string') { try { output = JSON.parse(output) } catch { throw failure('PARENT_NATIVE_WAIT_RESULT_INVALID') } } const task = harness === 'grok' ? output?.task_id : output?.shell_id @@ -44,7 +49,7 @@ export async function bindNativeWait(spool, event, harness) { // POSIXは同じNode PID。PowerShell 7は公式task PIDの子としてNodeが動く。 const taskOwner = processIdentity(pid) if (!taskOwner || !processDescendsFrom(waiter.owner, taskOwner)) throw failure('PARENT_NATIVE_WAIT_PROCESS_MISMATCH') - waiter.native_task = { id: String(task), pid, process_identity: taskOwner, input: expected, at: new Date().toISOString() } + waiter.native_task = { id: String(task), pid, process_identity: taskOwner, input: expected, hook_input: input, at: new Date().toISOString() } armParentState(saved) }) await spool.publishHealth() From 43cba5ff83006493c8a416245cacebf17e913f25 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:38:55 +0900 Subject: [PATCH 70/72] =?UTF-8?q?lease=E5=8F=97=E5=85=A5=E3=82=92=E5=8D=94?= =?UTF-8?q?=E8=AA=BF=E4=B8=AD=E6=96=AD=E3=81=97=E6=89=80=E6=9C=89process?= =?UTF-8?q?=E3=81=A8room=E3=82=92=E5=9B=9E=E5=8F=8E=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../parent-product-acceptance/README.md | 2 + .../parent-product-acceptance/aiterm.mjs | 20 +++- .../scenarios-cancellation.mjs | 98 +++++++++++++++ .../scenarios-cancellation.test.mjs | 113 ++++++++++++++++++ .../scenarios-context.mjs | 16 +-- .../scenarios-fixtures.mjs | 36 +++--- .../scenarios-lease-run.mjs | 53 ++++---- .../scenarios-lease-stop.mjs | 20 ++++ .../scenarios-native.mjs | 9 +- .../parent-product-acceptance/scenarios.mjs | 10 +- 10 files changed, 325 insertions(+), 52 deletions(-) create mode 100644 experiments/parent-product-acceptance/scenarios-cancellation.mjs create mode 100644 experiments/parent-product-acceptance/scenarios-cancellation.test.mjs create mode 100644 experiments/parent-product-acceptance/scenarios-lease-stop.mjs diff --git a/experiments/parent-product-acceptance/README.md b/experiments/parent-product-acceptance/README.md index e197c720..443fe52b 100644 --- a/experiments/parent-product-acceptance/README.md +++ b/experiments/parent-product-acceptance/README.md @@ -58,6 +58,8 @@ focused test: `node --test experiments/parent-product-acceptance/evidence.test.m lease単独runは`scenarios-lease-run.mjs`へ導入物・tarball SHA・製品source/digest/version・`--runner-commit`を渡す。短時間runとroom/processを分離し、実際の製品期限まで待つ。通常HOMEと公式認証を維持し、global設定や認証を長時間fixtureへ複製しない。実行moduleに未commit差分があればprovenance照合で止まる。 +専用leaseの中断は全OS共通で `node experiments/parent-product-acceptance/scenarios-lease-stop.mjs --out <そのrunの出力dir> --runner-commit <起動時のcontroller commit>` を1回呼ぶ。停止入口は専用run UUID・controllerの保存PID/start・要求tokenを照合し、要求fileを介してAbortControllerへ伝える。`finished_at`、typed中断failure、fixture/room回収成功、成功成績0、controller本人の実終了まで確認して返す。POSIXのSIGTERM/SIGINTも同じ中断へ適合する。WindowsでNode child.kill(SIGTERM)によるcontroller強制終了はこの入口の代用にしない。中断要求は製品の実lease時間を短縮せず、そのrunを失敗で終える。待機のtimerは中断時に撤去し、回収用の待機はabortしない。過去runの制御記録がある出力dirは再利用せず、原記録を保持する。stop入口と中断moduleもrunner provenanceのGit blob照合対象になる。 + Codexのprobe期限試験は`scenarios-queue-fault.mjs`で、正規MCPのreceiver検証後に自己watcherが生成した初回app-serverだけをOS停止する。製品のspool・時計・RPC応答は変更しない。原RPC、同CIDのMCP completed item、PID/start、実deadlineとspool/room healthを保存する。期限failureの観測上限は全OS共通1500msで、実lagを保存し、期限前・時刻欠落・health不一致・上限超過は失敗にする。Windowsの実OS停止APIは未確認であり、試験手順の実装を実機合格と扱わない。 Codexの通常lease試験は起動前に自己processへ読取りobserverを設定し、短命app-serverの実spawnから公式queue/add request/responseとcloseまでを同じPID/startで記録する。OS停止は行わない。20msのprocess samplingで見逃した接続を成功に数えず、配送ID・受付ID・実接続終了・同identity消失が一致する原証拠を要求する。複数配送は別の実接続へ照合する。 diff --git a/experiments/parent-product-acceptance/aiterm.mjs b/experiments/parent-product-acceptance/aiterm.mjs index 8a7eccac..d807e8e0 100644 --- a/experiments/parent-product-acceptance/aiterm.mjs +++ b/experiments/parent-product-acceptance/aiterm.mjs @@ -2,15 +2,17 @@ // 結果は公式のstructuredContentだけを読み、人間向けtextは解釈しない。 import { spawn } from 'node:child_process' import { createInterface } from 'node:readline' +import { checkCancellation, closeOwnedChild } from './scenarios-cancellation.mjs' const typed = (code, message) => Object.assign(new Error(message), { code }) -export async function openAiterm({ executable = process.env.PEERTABLE_ACCEPTANCE_AITERM ?? 'aiterm-mcp', args = [] } = {}) { +export async function openAiterm({ executable = process.env.PEERTABLE_ACCEPTANCE_AITERM ?? 'aiterm-mcp', args = [], signal } = {}) { + checkCancellation(signal) const child = spawn(executable, args, { stdio: ['pipe', 'pipe', 'pipe'], shell: process.platform === 'win32' }) const pending = new Map() let seq = 0, stderr = '' child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-4000) }) - const fail = error => { for (const item of pending.values()) item.reject(error); pending.clear() } + const fail = error => { for (const item of pending.values()) { clearTimeout(item.timer); item.reject(error) }; pending.clear() } child.on('error', fail) child.on('exit', code => fail(typed('ACCEPTANCE_AITERM_EXITED', `aiterm-mcpが終了しました code=${code} ${stderr}`))) createInterface({ input: child.stdout }).on('line', line => { @@ -19,14 +21,22 @@ export async function openAiterm({ executable = process.env.PEERTABLE_ACCEPTANCE const item = pending.get(row.id) if (!item) return pending.delete(row.id) + clearTimeout(item.timer) row.error ? item.reject(typed('ACCEPTANCE_AITERM_RPC', JSON.stringify(row.error))) : item.resolve(row.result) }) const request = (method, params) => new Promise((resolve, reject) => { const id = ++seq - pending.set(id, { resolve, reject }) + const timer = setTimeout(() => { pending.delete(id); reject(typed('ACCEPTANCE_AITERM_RPC_TIMEOUT', `${method}の公式応答が60秒以内にありません`)) }, 60000) + pending.set(id, { resolve, reject, timer }) child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n') }) - await request('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'peertable_parent_acceptance', version: '1' } }) + // session作成前の初期化だけは中断でstdioを閉じられる。session操作の応答は所有名を保持して完了を待つ。 + let initializationCleanup + const abortInitialize = () => { child.stdin.end(); initializationCleanup = closeOwnedChild(child); initializationCleanup.catch(fail) } + signal?.addEventListener('abort', abortInitialize, { once: true }) + try { await request('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'peertable_parent_acceptance', version: '1' } }); checkCancellation(signal) } + catch (error) { fail(error); await (initializationCleanup ?? closeOwnedChild(child)); checkCancellation(signal); throw error } + finally { signal?.removeEventListener('abort', abortInitialize) } child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n') const call = async (name, args) => { const result = await request('tools/call', { name, arguments: args }) @@ -59,6 +69,6 @@ export async function openAiterm({ executable = process.env.PEERTABLE_ACCEPTANCE if (closed.session_id !== session_id || !['closed', 'already_closed'].includes(closed.outcome)) throw typed('ACCEPTANCE_AITERM_CLOSE', `pty_closeの結果が不正です: ${JSON.stringify(closed)}`) return closed }, - async end() { child.stdin.end(); await new Promise(resolve => { const timer = setTimeout(() => { child.kill(); resolve() }, 3000); child.once('exit', () => { clearTimeout(timer); resolve() }) }) }, + async end() { child.stdin.end(); await closeOwnedChild(child) }, } } diff --git a/experiments/parent-product-acceptance/scenarios-cancellation.mjs b/experiments/parent-product-acceptance/scenarios-cancellation.mjs new file mode 100644 index 00000000..a806813a --- /dev/null +++ b/experiments/parent-product-acceptance/scenarios-cancellation.mjs @@ -0,0 +1,98 @@ +// 専用runの中断だけを所有する。待機を捨てず、回収用の待機にはsignalを渡さない。 +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { randomUUID } from 'node:crypto' + +const fail = (code, message) => { throw Object.assign(new Error(message), { code }) } +export const interruption = reason => Object.assign(new Error('専用lease runを中断しました'), { code: 'ACCEPTANCE_LEASE_INTERRUPTED', detail: reason }) +export function checkCancellation(signal) { if (signal?.aborted) throw signal.reason?.code ? signal.reason : interruption(String(signal.reason ?? 'abort')) } + +export function waitDelay(ms, signal) { + checkCancellation(signal) + return new Promise((resolveWait, reject) => { + const finish = error => { clearTimeout(timer); signal?.removeEventListener('abort', abort); error ? reject(error) : resolveWait() } + const abort = () => { try { checkCancellation(signal) } catch (error) { finish(error) } } + const timer = setTimeout(() => finish(), ms) + signal?.addEventListener('abort', abort, { once: true }) + }) +} +export async function waitUntil(label, probe, ms = 300000, every = 1000, { signal, timeoutCode = 'ACCEPTANCE_TIMEOUT' } = {}) { + const end = Date.now() + ms + for (;;) { checkCancellation(signal); const value = await probe(); checkCancellation(signal); if (value) return value; if (Date.now() >= end) fail(timeoutCode, label); await waitDelay(Math.min(every, Math.max(1, end - Date.now())), signal) } +} + +const read = file => JSON.parse(readFileSync(file, 'utf8')) +const same = (a, b) => a && b && a.pid === b.pid && a.started === b.started + +export function createLeaseCancellation({ out, pkg, runner, platform, signal, signals = process.platform !== 'win32' }) { + const directory = join(resolve(out), 'private'); mkdirSync(directory, { recursive: true, mode: 0o700 }) + const descriptorFile = join(directory, 'lease-control.json'), requestFile = join(directory, 'lease-stop-request.json') + // 過去runのsummary/制御記録を上書きしない。停止要求も同じrun UUIDに限定する。 + if (existsSync(descriptorFile)) fail('ACCEPTANCE_LEASE_OUTPUT_ALREADY_USED', descriptorFile) + const owner = platform.processIdentity(process.pid) + if (!owner || !platform.sameProcess(owner)) fail('ACCEPTANCE_LEASE_OWNER_MISSING', 'lease controller本人を確認できません') + const descriptor = { schema: 'peertable.lease-control.v1', run_id: randomUUID(), request_token: randomUUID(), owner, pkg: resolve(pkg), out: resolve(out), runner, request_file: requestFile, started_at: new Date().toISOString() } + writeFileSync(descriptorFile, JSON.stringify(descriptor, null, 2), { flag: 'wx', mode: 0o600 }) + const controller = new AbortController(), abort = reason => { if (!controller.signal.aborted) controller.abort(interruption(reason)) } + const linked = () => abort({ source: 'caller_signal', reason: String(signal.reason ?? 'abort') }) + signal?.addEventListener('abort', linked, { once: true }); if (signal?.aborted) linked() + const signalHandlers = signals ? ['SIGTERM', 'SIGINT'].map(name => { const handler = () => abort({ source: 'posix_signal', signal: name }); process.on(name, handler); return [name, handler] }) : [] + const timer = setInterval(() => { + if (!existsSync(requestFile) || controller.signal.aborted) return + try { + const request = read(requestFile) + if (request.schema !== 'peertable.lease-stop-request.v1' || request.run_id !== descriptor.run_id || request.request_token !== descriptor.request_token || !same(request.owner, owner)) fail('ACCEPTANCE_LEASE_STOP_OWNER_MISMATCH', '停止要求のrun/本人が一致しません') + abort({ source: 'request_file', request_id: request.request_id, requested_at: request.requested_at }) + } catch (error) { controller.abort(error) } + }, 100) + return { signal: controller.signal, descriptor, descriptor_file: descriptorFile, close() { clearInterval(timer); signal?.removeEventListener('abort', linked); for (const [name, handler] of signalHandlers) process.removeListener(name, handler) } } +} + +// stop入口は自己runの要求→finished summary→保存した本人の実消失まで1回で確認する。 +export async function requestLeaseStop({ out, runner, platform, timeout = 180000 }) { + const root = resolve(out), descriptor = read(join(root, 'private/lease-control.json')) + if (descriptor.schema !== 'peertable.lease-control.v1' || descriptor.out !== root || descriptor.request_file !== join(root, 'private/lease-stop-request.json') || descriptor.runner.commit !== runner.commit) fail('ACCEPTANCE_LEASE_STOP_OWNER_MISMATCH', '専用runのpath/controllerが一致しません') + const summaryFile = join(root, 'summary.json') + const finished = () => { + if (!existsSync(summaryFile)) return null + const summary = read(summaryFile) + if (summary.run_id !== descriptor.run_id || !same(summary.owner, descriptor.owner)) fail('ACCEPTANCE_LEASE_STOP_OWNER_MISMATCH', 'summaryのrun/本人が一致しません') + return summary.finished_at ? summary : null + } + let summary = finished() + if (!summary) { + if (!platform.sameProcess(descriptor.owner)) fail('ACCEPTANCE_LEASE_STOP_CONTROLLER_GONE', 'finished summaryのないrunは既に終了しています') + platform.atomicJson(descriptor.request_file, { schema: 'peertable.lease-stop-request.v1', run_id: descriptor.run_id, request_token: descriptor.request_token, owner: descriptor.owner, request_id: randomUUID(), requested_at: new Date().toISOString() }) + summary = await waitUntil('専用leaseの中断と回収完了', finished, timeout, 100, { timeoutCode: 'ACCEPTANCE_LEASE_STOP_TIMEOUT' }) + } + await waitUntil('専用lease controller本人の実終了', () => !platform.sameProcess(descriptor.owner), timeout, 100, { timeoutCode: 'ACCEPTANCE_LEASE_STOP_TIMEOUT' }) + if (summary.status !== 'failed' || !summary.errors?.some(error => error.code === 'ACCEPTANCE_LEASE_INTERRUPTED') || summary.cleanup?.status !== 'passed' || summary.cases.length) fail('ACCEPTANCE_LEASE_STOP_RESULT_INVALID', '中断failure/回収完了/成功成績0が揃いません') + return { schema: 'peertable.lease-stop-result.v1', run_id: descriptor.run_id, status: 'stopped', finished_at: summary.finished_at, controller_gone: true, cleanup: summary.cleanup, errors: summary.errors } +} + +export async function finishLeaseExecution({ summary, signal, prepare, execute, cleanup, save }) { + const errors = []; let value, preparationCleanup + try { checkCancellation(signal); const context = await prepare(); checkCancellation(signal); value = await execute(context); checkCancellation(signal) } + catch (error) { preparationCleanup = error.cleanup; errors.push({ code: error.code ?? 'ACCEPTANCE_LEASE_RUN_FAILED', message: error.message, detail: error.detail }) } + finally { + summary.current_boundary = 'cleanup' + try { save() } catch (error) { errors.push({ code: error.code ?? 'ACCEPTANCE_LEASE_SUMMARY_WRITE_FAILED', message: error.message }) } + try { summary.cleanup = { ...await cleanup(), status: preparationCleanup?.status === 'failed' ? 'failed' : 'passed', ...(preparationCleanup ? { preparation: preparationCleanup } : {}) } } + catch (error) { summary.cleanup = { status: 'failed' }; errors.push({ code: error.code ?? 'ACCEPTANCE_LEASE_CLEANUP_FAILED', message: error.message }) } + // cleanup途中の中断も成功にしない。cleanup自体はsignalで打ち切らない。 + if (signal?.aborted && !errors.some(error => error.code === 'ACCEPTANCE_LEASE_INTERRUPTED')) { const error = interruption(signal.reason?.detail ?? String(signal.reason)); errors.push({ code: error.code, message: error.message, detail: error.detail }) } + summary.status = errors.length ? 'failed' : 'passed'; summary.errors = errors; summary.finished_at = new Date().toISOString(); save() + } + return { summary, value } +} + +// ChildProcessを所有する呼出し元だけが使う。exit待機のtimer/listenerは両経路で必ず撤去する。 +export async function closeOwnedChild(child, ms = 10000) { + if (child.exitCode !== null || child.signalCode !== null) return { pid: child.pid, exited: true, code: child.exitCode, signal: child.signalCode } + await new Promise((resolveExit, reject) => { + const exit = () => { clearTimeout(timer); child.removeListener('exit', exit); resolveExit() } + const timer = setTimeout(() => { child.removeListener('exit', exit); reject(Object.assign(new Error('所有room processが終了しません'), { code: 'ACCEPTANCE_LEASE_ROOM_ALIVE' })) }, ms) + child.once('exit', exit); child.kill('SIGTERM') + }) + return { pid: child.pid, exited: true, code: child.exitCode, signal: child.signalCode } +} diff --git a/experiments/parent-product-acceptance/scenarios-cancellation.test.mjs b/experiments/parent-product-acceptance/scenarios-cancellation.test.mjs new file mode 100644 index 00000000..04ed175c --- /dev/null +++ b/experiments/parent-product-acceptance/scenarios-cancellation.test.mjs @@ -0,0 +1,113 @@ +// 実owned dummyと待機timerの回収だけを検証する。製品lease/正式成績を作らない。 +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { processIdentity, sameProcess, atomicJson } from '../../skill/scripts/parent-platform.mjs' +import { waitDelay, waitUntil, createLeaseCancellation, requestLeaseStop, finishLeaseExecution } from './scenarios-cancellation.mjs' +import { createNativeFixtureFactory } from './scenarios-fixtures.mjs' +import { openAiterm } from './aiterm.mjs' + +const platform = { processIdentity, sameProcess, atomicJson }, runner = { commit: 'a'.repeat(40), modules_sha256: {} } +const moduleUrl = pathToFileURL(join(import.meta.dirname, 'scenarios-cancellation.mjs')).href +const scenariosUrl = pathToFileURL(join(import.meta.dirname, 'scenarios.mjs')).href +const platformUrl = pathToFileURL(join(import.meta.dirname, '../../skill/scripts/parent-platform.mjs')).href +const privateDir = t => { const dir = mkdtempSync(join(tmpdir(), 'pt-lease-interrupt-')); t.after(() => rmSync(dir, { recursive: true, force: true })); return dir } + +test('24時間待機のabortはtimer/listenerを撤去し、cleanupの待機を打ち切らない', async () => { + const controller = new AbortController(), summary = { cases: [] }; let probes = 0, cleanup = false + const done = finishLeaseExecution({ summary, signal: controller.signal, save() {}, prepare: async () => {}, execute: () => waitUntil('通常24時間待機', () => { probes++; return false }, 86400000, 86400000, { signal: controller.signal }), cleanup: async () => { await waitDelay(25); cleanup = true; return { fixtures_closed: true } } }) + setTimeout(() => controller.abort('専用試験中断'), 20) + await done + const count = probes; await waitDelay(40) + assert.equal(probes, count); assert.equal(cleanup, true); assert.equal(summary.status, 'failed'); assert.equal(summary.cleanup.status, 'passed') + assert.equal(summary.errors[0].code, 'ACCEPTANCE_LEASE_INTERRUPTED'); assert.ok(summary.finished_at); assert.deepEqual(summary.cases, []) +}) + +const driver = mode => ` +import {mkdirSync,writeFileSync} from 'node:fs';import {join} from 'node:path';import {spawn} from 'node:child_process'; +import {createLeaseCancellation,finishLeaseExecution,waitUntil,closeOwnedChild} from ${JSON.stringify(moduleUrl)}; +import {runScenario} from ${JSON.stringify(scenariosUrl)};import * as platform from ${JSON.stringify(platformUrl)}; +const out=process.argv[2],mode=${JSON.stringify(mode)},runner=${JSON.stringify(runner)}; +const control=createLeaseCancellation({out,pkg:out,runner,platform}); +const summary={schema:'peertable.lease-run.v1',run_id:control.descriptor.run_id,owner:control.descriptor.owner,status:'running',cases:[]}; +const save=()=>platform.atomicJson(join(out,'summary.json'),summary);save(); +const assets=[];const owned=async()=>{const child=spawn(process.execPath,['-e','setInterval(()=>{},86400000)'],{stdio:'ignore'});assets.push(child);const owner=platform.processIdentity(child.pid);return owner}; +try {await finishLeaseExecution({summary,signal:control.signal,save,prepare:async()=>{ +summary.assets=[await owned(),await owned()];save(); +if(mode==='prepare'){summary.waiting=mode;save();process.stdout.write('READY\\n');await waitUntil('準備中',()=>false,86400000,86400000,{signal:control.signal});} +return {harness:'claude',session:'own',pageChars:100,signal:control.signal,actions: mode==='missing'?{}:{lease_expiry_arm:async()=>{summary.waiting=mode;save();process.stdout.write('READY\\n');await waitUntil('実時間lease待機',()=>false,86400000,86400000,{signal:control.signal})},deliver:async()=>{},lease_expiry_observe:async()=>{},receiver_rearm:async()=>{},pending_recover:async()=>{}},finalize:async()=>{summary.finalized=true}}; +},execute:context=>runScenario('lease',context),cleanup:async()=>{for(const child of assets)await closeOwnedChild(child);return {fixtures_closed:true,room:{exited:true},assets_gone:summary.assets.every(owner=>!platform.sameProcess(owner))}}});}finally{control.close()} +process.exitCode=summary.status==='passed'?0:1; +` +async function startDriver(t, mode) { + const out = privateDir(t), file = join(out, 'driver.mjs'); writeFileSync(file, driver(mode)) + const child = spawn(process.execPath, [file, out], { stdio: ['ignore', 'pipe', 'pipe'] }) + const exited = once(child, 'exit'); let stderr = ''; child.stderr.on('data', chunk => { stderr += chunk }) + t.after(() => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL') }) + if (mode !== 'missing') await waitUntil('dummy controller待機開始', () => existsSync(join(out, 'summary.json')) && JSON.parse(readFileSync(join(out, 'summary.json'), 'utf8')).waiting === mode, 10000, 10) + return { out, child, exited, stderr: () => stderr } +} +for (const mode of ['prepare', 'action']) test(`${mode}待機を正規stop要求で中断し、実owned childとcontrollerを回収する`, async t => { + const { out, child, exited, stderr } = await startDriver(t, mode) + const result = await requestLeaseStop({ out, runner, platform, timeout: 15000 }) + assert.equal(result.controller_gone, true); assert.equal(result.cleanup.assets_gone, true); assert.equal(result.status, 'stopped') + await exited; assert.equal(child.exitCode, 1, stderr()) + const summary = JSON.parse(readFileSync(join(out, 'summary.json'), 'utf8')) + for (const owner of summary.assets) assert.equal(Boolean(sameProcess(owner)), false) + if (mode === 'action') assert.equal(summary.finalized, true) +}) +test('adapter不足でもrunScenario.finallyを実行してowned child/roomを回収する', async t => { + const { out, child, exited, stderr } = await startDriver(t, 'missing'); await exited + assert.equal(child.exitCode, 1, stderr()) + const summary = JSON.parse(readFileSync(join(out, 'summary.json'), 'utf8')) + assert.equal(summary.finalized, true); assert.equal(summary.errors[0].code, 'ACCEPTANCE_SCENARIO_ADAPTER_MISSING'); assert.equal(summary.cleanup.assets_gone, true); assert.ok(summary.finished_at) +}) +test('POSIX SIGTERMを専用runの協調中断へ適合する', { skip: process.platform === 'win32' }, async t => { + const { out, child, exited, stderr } = await startDriver(t, 'action'); child.kill('SIGTERM'); await exited + assert.equal(child.exitCode, 1, stderr()); assert.equal(child.signalCode, null) + const summary = JSON.parse(readFileSync(join(out, 'summary.json'), 'utf8')) + assert.equal(summary.errors[0].code, 'ACCEPTANCE_LEASE_INTERRUPTED'); assert.equal(summary.cleanup.assets_gone, true); assert.ok(summary.finished_at) +}) +test('異なるrun/controllerの停止要求を送らず、回収失敗を成功にしない', async t => { + const out = privateDir(t), control = createLeaseCancellation({ out, pkg: out, runner, platform, signals: false }) + t.after(() => control.close()) + await assert.rejects(requestLeaseStop({ out, runner: { commit: 'b'.repeat(40) }, platform }), { code: 'ACCEPTANCE_LEASE_STOP_OWNER_MISMATCH' }) + assert.equal(existsSync(control.descriptor.request_file), false) + const summary = { cases: [] }; await finishLeaseExecution({ summary, save() {}, prepare: async () => { throw Object.assign(new Error('準備と回収の失敗'), { cleanup: { status: 'failed' } }) }, execute() {}, cleanup: async () => ({ fixtures_closed: false }) }) + assert.equal(summary.cleanup.status, 'failed'); assert.equal(summary.status, 'failed'); assert.ok(summary.finished_at) +}) +test('cleanup開始のsummary保存失敗でも所有資産の回収を実行する', async () => { + const summary = { cases: [] }; let saved = 0, cleaned = false + await finishLeaseExecution({ summary, prepare: async () => {}, execute: async () => {}, cleanup: async () => { cleaned = true; return { fixtures_closed: true } }, save() { if (++saved === 1) throw Object.assign(new Error('保存境界failure'), { code: 'EIO' }) } }) + assert.equal(cleaned, true); assert.equal(summary.status, 'failed'); assert.equal(summary.errors[0].code, 'EIO'); assert.equal(summary.cleanup.status, 'passed'); assert.ok(summary.finished_at) +}) +test('factory.prepare途中の中断は登録済みown paneを閉じて実process消失を待つ', async t => { + const out = privateDir(t), pkg = join(out, 'pkg'); const fs = await import('node:fs'); fs.mkdirSync(join(pkg, 'skill/scripts/parent-receivers'), { recursive: true }) + writeFileSync(join(pkg, 'skill/scripts/parent-platform.mjs'), `export * from ${JSON.stringify(platformUrl)};`) + writeFileSync(join(pkg, 'skill/scripts/parent-runtime.mjs'), 'export const projectEndpoints=()=>[];') + writeFileSync(join(pkg, 'skill/scripts/parent-connect.mjs'), 'export const ownsParentConnection=()=>false;') + writeFileSync(join(pkg, 'skill/scripts/parent-receivers/codex.mjs'), 'export const codexConnection=()=>{throw new Error("focusedでは使いません")};') + const signal = new AbortController(); let child, owner, closed = false + const adapter = { resolveCli() {}, startup() {}, transcript() {}, read() {}, bind(fixture) { return { ...this, async prepare() { + child = spawn(process.execPath, ['-e', 'setInterval(()=>{},86400000)'], { stdio: 'ignore' }); owner = processIdentity(child.pid) + fixture.pty = 'own-dummy-pane'; fixture.paneOwner = owner; fixture.nativeOwners.push(owner) + fixture.aiterm = { observe: async () => ({ pane_process: { pid: child.pid } }), close: async name => { assert.equal(name, 'own-dummy-pane'); const { closeOwnedChild } = await import('./scenarios-cancellation.mjs'); await closeOwnedChild(child); closed = true }, end: async () => {} } + setTimeout(() => signal.abort('prepare中断'), 20) + await waitUntil('prepare', () => false, 86400000, 86400000, { signal: signal.signal }) + } } } } + const factory = await createNativeFixtureFactory({ pkg, out, sourceMeta: { harness: 'grok', room: 'own' }, apiFor: () => {}, surfaceAdapters: { grok: adapter }, signal: signal.signal }) + await assert.rejects(factory.open(), { code: 'ACCEPTANCE_LEASE_INTERRUPTED' }); await factory.close() + assert.equal(closed, true); assert.equal(Boolean(sameProcess(owner)), false) +}) +test('Aiterm初期化途中の中断も自己stdio childを実回収する', async t => { + const out = privateDir(t), file = join(out, 'unresponsive.mjs'), identity = join(out, 'owner.json') + writeFileSync(file, `import {writeFileSync} from 'node:fs';import * as platform from ${JSON.stringify(platformUrl)};writeFileSync(process.argv[2],JSON.stringify(platform.processIdentity(process.pid)));process.stdin.on('data',()=>{});setInterval(()=>{},86400000);`) + const controller = new AbortController(), pending = openAiterm({ executable: process.execPath, args: [file, identity], signal: controller.signal }) + await waitUntil('自己stdio起動', () => existsSync(identity), 10000, 10); controller.abort('init中断') + await assert.rejects(pending, { code: 'ACCEPTANCE_LEASE_INTERRUPTED' }); assert.equal(Boolean(sameProcess(JSON.parse(readFileSync(identity, 'utf8')))), false) +}) diff --git a/experiments/parent-product-acceptance/scenarios-context.mjs b/experiments/parent-product-acceptance/scenarios-context.mjs index f24b4025..3d526ac1 100644 --- a/experiments/parent-product-acceptance/scenarios-context.mjs +++ b/experiments/parent-product-acceptance/scenarios-context.mjs @@ -8,13 +8,10 @@ import { judgeAudience, sha256 } from './evidence.mjs' import { isDeepStrictEqual } from 'node:util' import { scenarioPlan, scenarioNames } from './scenarios.mjs' import { readJsonl } from './harness.mjs' +import { waitUntil, waitDelay, checkCancellation } from './scenarios-cancellation.mjs' const fail = (code, detail) => { throw Object.assign(new Error(detail), { code }) } -const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) -const until = async (label, probe, ms = 300000, every = 1000) => { - const deadline = Date.now() + ms - for (;;) { const value = await probe(); if (value) return value; if (Date.now() >= deadline) fail('ACCEPTANCE_TIMEOUT', label); await sleep(every) } -} +const cleanupUntil = waitUntil // 試験roomの手前だけに置くproxy。SSE切断とreceipt HTTP失敗を製品を改造せず実境界へ注入する。 // runnerの投稿APIはbackendへ向け、親のsetup-state.server_urlだけをproxyのURLへ向ける。 @@ -174,13 +171,16 @@ export function cursorIdleCompletion(events, last, session) { } export async function createScenarioContext(options) { + checkCancellation(options.signal) + const until = (label, probe, ms, every) => waitUntil(label, probe, ms, every, { signal: options.signal }) + const sleep = ms => waitDelay(ms, options.signal) const { meta, spool, api, observe, submit, file, pkg, projectDir, privateDir, proxy, bin, nativeActions = {}, screen, nativeStopFile } = options const importInstalled = name => import(pathToFileURL(join(pkg, 'skill/scripts', name)).href) const { PAGE_CHARS } = await importInstalled('parent-delivery.mjs') const { sameProcess, processIdentity, processDescendsFrom, shellCommand } = await importInstalled('parent-platform.mjs') const recipient = spool.read().name, pending = [], faultState = new Map(), actions = {}, nativeRegistrations = [] const targets = new Map([['self', { meta, spool, api, observe, submit, file, screen }]]) - const context = { harness: meta.harness, session: meta.parent_session, pageChars: PAGE_CHARS, actions, + const context = { signal: options.signal, harness: meta.harness, session: meta.parent_session, pageChars: PAGE_CHARS, actions, registerEndpoint(name, target) { if (!name || !target?.spool || !target.meta?.parent_session || !target.file || typeof target.api !== 'function' || typeof target.observe !== 'function' || typeof target.submit !== 'function') fail('ACCEPTANCE_ENDPOINT_REGISTRATION_INVALID', name) if (target.meta.harness !== meta.harness || target.meta.source_commit !== meta.source_commit || target.meta.runtime_digest !== meta.runtime_digest || target.meta.package_version !== meta.package_version) fail('ACCEPTANCE_ENDPOINT_SOURCE_MISMATCH', name) @@ -577,8 +577,8 @@ export async function createScenarioContext(options) { // releaseはこの試験が作ったprocessだけが読む。startに失敗しても後発のchildを待機させない。 writeFileSync(work.release, JSON.stringify({ released_at: new Date().toISOString(), cleanup: true })) if (work.owner && sameProcess(work.owner)) { - try { await until('試験作業processの後片付け', () => !sameProcess(work.owner), 10000, 100) } - catch (error) { if (error.code !== 'ACCEPTANCE_TIMEOUT') throw error; if (sameProcess(work.owner)) process.kill(work.owner.pid, 'SIGKILL'); await until('強制終了後の試験process消失', () => !sameProcess(work.owner), 10000, 100) } + try { await cleanupUntil('試験作業processの後片付け', () => !sameProcess(work.owner), 10000, 100) } + catch (error) { if (error.code !== 'ACCEPTANCE_TIMEOUT') throw error; if (sameProcess(work.owner)) process.kill(work.owner.pid, 'SIGKILL'); await cleanupUntil('強制終了後の試験process消失', () => !sameProcess(work.owner), 10000, 100) } } } } diff --git a/experiments/parent-product-acceptance/scenarios-fixtures.mjs b/experiments/parent-product-acceptance/scenarios-fixtures.mjs index f437d1f4..657b94a6 100644 --- a/experiments/parent-product-acceptance/scenarios-fixtures.mjs +++ b/experiments/parent-product-acceptance/scenarios-fixtures.mjs @@ -10,10 +10,10 @@ import { openAiterm } from './aiterm.mjs' import { startupAction, readTranscript, transcriptPath } from './harness.mjs' import { parseDelivered, sha256 } from './evidence.mjs' import { nativeInvocation, resolveOfficialCli } from './scenarios-surfaces.mjs' +import { waitUntil, waitDelay, checkCancellation } from './scenarios-cancellation.mjs' const fail = (code, message) => { throw Object.assign(new Error(message), { code }) } -const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) -const until = async (label, probe, ms = 180000, interval = 250) => { const end = Date.now() + ms; for (;;) { const value = await probe(); if (value) return value; if (Date.now() >= end) fail('ACCEPTANCE_FIXTURE_TIMEOUT', label); await sleep(interval) } } +const cleanupUntil = (label, probe, ms = 180000, every = 250) => waitUntil(label, probe, ms, every, { timeoutCode: 'ACCEPTANCE_FIXTURE_TIMEOUT' }) const contained = (root, file) => { const path = relative(realpathSync(root), file); return path !== '' && !path.startsWith('..') && !isAbsolute(path) } const writeJson = (file, value) => { mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); writeFileSync(file, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 }) } @@ -96,7 +96,7 @@ export function ordinaryConfiguration(harness) { // 親終了直後の中間状態で失敗にしない。製品による自己停止と索引撤去を実際に待つ。 export async function waitOwnedFixtureExit({ readEndpoints, sameProcess, knownOwners = [], indexExists, timeout = 30000 }) { - const final = await until('own親/process/索引の製品自己撤去', () => { + const final = await cleanupUntil('own親/process/索引の製品自己撤去', () => { const endpoints = readEndpoints(), states = endpoints.map(endpoint => ({ id: endpoint.id, state: endpoint.read() })) const alive = [...knownOwners, ...states.flatMap(item => [item.state.caller?.owner, item.state.watcher, item.state.waiter?.owner].filter(Boolean))].filter(owner => sameProcess(owner)) return !alive.length && states.every(item => item.state.runtime === 'stopped' && !indexExists(item.id)) ? { endpoints: states.map(item => ({ id: item.id, runtime: item.state.runtime })), known_processes_gone: true, indexes_gone: true } : null @@ -111,7 +111,8 @@ export function assertOnlyProjectTrustChanged(before, after, project, { remove = if (!isDeepStrictEqual(expected, after)) fail('ACCEPTANCE_PROJECT_TRUST_OTHER_SETTINGS_CHANGED', '専用project trust以外の実効設定が変わりました') } -export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUrl, sourceMeta, apiFor, model, surfaceAdapters = {} }) { +export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUrl, sourceMeta, apiFor, model, surfaceAdapters = {}, signal }) { + checkCancellation(signal) if (!apiFor) { const token = /^PEERTABLE_POST_TOKEN=(.+)$/mu.exec(readFileSync(tokenFile, 'utf8'))?.[1] if (!token) fail('ACCEPTANCE_FIXTURE_ROOM_TOKEN_MISSING', '試験roomのtoken参照を確認できません') @@ -125,12 +126,15 @@ export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUr return { api: room => apiFor(room), async open({ harness = sourceMeta.harness, room = sourceMeta.room, name = `fixture-${randomUUID().slice(0, 8)}`, initialJoin = true, sessionSettings = null, prepare } = {}) { + checkCancellation(signal) + const until = (label, probe, ms = 180000, every = 250) => waitUntil(label, probe, ms, every, { signal, timeoutCode: 'ACCEPTANCE_FIXTURE_TIMEOUT' }) + const sleep = ms => waitDelay(ms, signal) const directory = join(root, `${harness}-${randomUUID()}`); mkdirSync(directory, { recursive: true, mode: 0o700 }) const project = join(directory, 'project'); mkdirSync(join(project, '.team'), { recursive: true, mode: 0o700 }); writeJson(join(project, '.team/setup-state.json'), { room, server_url: serverUrl, mode: 'adhoc' }) const configuration = ordinaryConfiguration(harness) if (['claude', 'codex'].includes(harness) && !ownsParentConnection(harness)) fail('ACCEPTANCE_FIXTURE_PARENT_NOT_CONNECTED', '通常設定のPeertable所有登録が必要です。fixtureはglobal登録を変更しません') let adapter = surfaceAdapters[harness] ?? (['claude', 'codex'].includes(harness) ? { resolveCli: () => resolveOfficialCli(harness, platform), startup: view => startupAction(harness, view), transcript: session => transcriptPath(harness, session), read: file => readTranscript(harness, file) } : null) - const fixture = { directory, project, configuration, pkg, harness, name, room, pty: null, aiterm: null, target: null, session: null, owner: null, paneOwner: null, nativeOwners: [], receiverOwners: [], ready: false, backups: new Map(), closed: false, observations: [], sessionSettings, adapter } + const fixture = { signal, directory, project, configuration, pkg, harness, name, room, pty: null, aiterm: null, target: null, session: null, owner: null, paneOwner: null, nativeOwners: [], receiverOwners: [], ready: false, backups: new Map(), closed: false, observations: [], sessionSettings, adapter } if (adapter?.bind) adapter = adapter.bind(fixture) fixture.adapter = adapter if (!adapter?.resolveCli || !adapter.startup || !adapter.transcript || !adapter.read) fail('ACCEPTANCE_OFFICIAL_SURFACE_BOUNDARY_UNCONFIRMED', `${harness}: 公式task/transcriptの実測adapterが必要です`) @@ -296,8 +300,10 @@ export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUr writeJson(input, { executable: cli.executable, argv, invocation, project, tokenFile, processEnvironment }) // HOME・認証は通常のまま。専用runのtoken参照とOS observerだけを公式env_varsへ渡す。 writeFileSync(launcher, `import {readFileSync} from 'node:fs';import {spawn} from 'node:child_process';const p=JSON.parse(readFileSync(process.argv[2],'utf8'));const child=spawn(p.invocation.executable,p.invocation.argv,{cwd:p.project,stdio:'inherit',env:{...process.env,...p.processEnvironment,PEERTABLE_TOKEN_SOURCE_FILE:p.tokenFile}});child.on('exit',(code)=>process.exit(code??1));\n`, { mode: 0o600 }) - if (!fixture.aiterm) fixture.aiterm = await openAiterm() - if (!fixture.pty) fixture.pty = await fixture.aiterm.open(`pt-fixture-${randomUUID().slice(0, 8)}`, process.platform === 'win32' ? 'pwsh' : undefined) + checkCancellation(signal) + if (!fixture.aiterm) fixture.aiterm = await openAiterm({ signal }) + if (!fixture.pty) { fixture.pty = `pt-fixture-${randomUUID().slice(0, 8)}`; await fixture.aiterm.open(fixture.pty, process.platform === 'win32' ? 'pwsh' : undefined) } + checkCancellation(signal) writeJson(fixture.identityArtifact, { at: new Date().toISOString(), project, harness, pty: fixture.pty, pane: fixture.paneOwner, native: fixture.nativeOwners, receiver: fixture.receiverOwners }) await fixture.trackOwnProcesses() await fixture.aiterm.send(fixture.pty, platform.shellCommand(process.execPath, [launcher, input])) @@ -306,7 +312,7 @@ export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUr if (adapter.afterStartup) await adapter.afterStartup() return fixture } - fixture.submit = async text => { await fixture.aiterm.send(fixture.pty, text, false); await sleep(1500); await fixture.aiterm.key(fixture.pty, 'Enter') } + fixture.submit = async (text, { cleanup = false } = {}) => { if (!cleanup) checkCancellation(signal); await fixture.aiterm.send(fixture.pty, text, false); await waitDelay(1500, cleanup ? undefined : signal); await fixture.aiterm.key(fixture.pty, 'Enter'); if (!cleanup) checkCancellation(signal) } fixture.refreshTarget = async (previousEndpoint = null, { waitForVerified = true } = {}) => { const spool = await until('fixture parent_join', async () => { await fixture.trackOwnProcesses(); return projectEndpoints(project).find(item => item.id !== previousEndpoint && item.read().name === name && item.read().runtime !== 'stopped') }) // probe障害の対象も製品が作ったcallerとspoolを読む。verifiedをfixtureで作り足さない。 @@ -331,8 +337,8 @@ export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUr fixture.ready = false; fixture.owner = null; fixture.paneOwner = null return } - await fixture.submit(exit) - await until('fixture親終了', () => !platform.sameProcess(fixture.owner), 30000) + await fixture.submit(exit, { cleanup: true }) + await cleanupUntil('fixture親終了', () => !platform.sameProcess(fixture.owner), 30000) } fixture.stopOwnPane = async () => { await fixture.trackOwnProcesses() @@ -360,10 +366,12 @@ export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUr fixture.closed = true; fixtures.delete(fixture) } fixtures.add(fixture) - await fixture.ensureProjectTrust() - if (adapter.prepare) await adapter.prepare() - if (prepare) await prepare(fixture) - await fixture.launch(); if (initialJoin) await fixture.join(); return fixture + try { + await fixture.ensureProjectTrust(); checkCancellation(signal) + if (adapter.prepare) await adapter.prepare(); checkCancellation(signal) + if (prepare) await prepare(fixture); checkCancellation(signal) + await fixture.launch(); checkCancellation(signal); if (initialJoin) await fixture.join(); checkCancellation(signal); return fixture + } catch (error) { try { await fixture.close() } catch (cleanup) { throw Object.assign(new AggregateError([error, cleanup], 'fixture準備と自己回収に失敗しました'), { code: 'ACCEPTANCE_FIXTURE_CLEANUP_FAILED' }) }; throw error } }, async close() { const errors = []; for (const fixture of [...fixtures].reverse()) { try { await fixture.close() } catch (error) { errors.push({ code: error.code, message: error.message, directory: fixture.directory }) } } if (errors.length) fail('ACCEPTANCE_FIXTURE_CLEANUP_FAILED', JSON.stringify(errors)) }, } diff --git a/experiments/parent-product-acceptance/scenarios-lease-run.mjs b/experiments/parent-product-acceptance/scenarios-lease-run.mjs index 183e6f24..0b21d490 100644 --- a/experiments/parent-product-acceptance/scenarios-lease-run.mjs +++ b/experiments/parent-product-acceptance/scenarios-lease-run.mjs @@ -9,10 +9,10 @@ import { pathToFileURL, fileURLToPath } from 'node:url' import { createNativeScenarioContext } from './scenarios-native.mjs' import { runnerProvenance } from './runner-provenance.mjs' import { runScenario } from './scenarios.mjs' +import { createLeaseCancellation, finishLeaseExecution, waitUntil, checkCancellation, closeOwnedChild } from './scenarios-cancellation.mjs' import { buildCase, buildRecord, selfAudit, acceptCase, project } from './evidence.mjs' const fail = (code, message) => { throw Object.assign(new Error(message), { code }) } -const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) export async function runLeaseOnly(options) { const { pkg, out, harness, sourceCommit, sourceDigest, version, tarballSha256, model } = options if (!['claude', 'codex', 'cursor', 'grok'].includes(harness) || !/^[0-9a-f]{40}$/u.test(sourceCommit ?? '') || !/^[0-9a-f]{64}$/u.test(tarballSha256 ?? '')) fail('ACCEPTANCE_LEASE_PROVENANCE_REQUIRED', 'harness/source commit/pack tarballのSHAが必要です') @@ -24,39 +24,52 @@ export async function runLeaseOnly(options) { mkdirSync(out, { recursive: true, mode: 0o700 }) const privateDir = join(out, 'private'); mkdirSync(privateDir, { recursive: true, mode: 0o700 }) const logFile = join(privateDir, 'lease-progress.jsonl'), summaryFile = join(out, 'summary.json') - const summary = { schema: 'peertable.lease-run.v1', runner, status: 'running', harness, os: process.platform, source_commit: sourceCommit, runtime_digest: digest, package_version: installedVersion, started_at: new Date().toISOString(), current_boundary: 'prepare', cases: [] } - const log = (kind, fields = {}) => { const row = { at: new Date().toISOString(), kind, ...fields }; appendFileSync(logFile, JSON.stringify(project(row)) + '\n'); summary.current_boundary = kind; writeFileSync(summaryFile, JSON.stringify(project(summary), null, 2)); process.stdout.write(JSON.stringify(project(row)) + '\n') } + const platform = await import(pathToFileURL(join(pkg, 'skill/scripts/parent-platform.mjs')).href) + const cancellation = createLeaseCancellation({ out, pkg, runner, platform, signal: options.signal }) + const signal = cancellation.signal + try { + const summary = { run_id: cancellation.descriptor.run_id, owner: cancellation.descriptor.owner, schema: 'peertable.lease-run.v1', runner, status: 'running', harness, os: process.platform, source_commit: sourceCommit, runtime_digest: digest, package_version: installedVersion, started_at: new Date().toISOString(), current_boundary: 'prepare', cases: [] } + const log = (kind, fields = {}) => { const row = { at: new Date().toISOString(), kind, ...fields }; appendFileSync(logFile, JSON.stringify(project(row)) + '\n'); summary.current_boundary = kind; platform.atomicJson(summaryFile, project(summary)); process.stdout.write(JSON.stringify(project(row)) + '\n') } + const save = () => platform.atomicJson(summaryFile, project(summary)) + save() + let server, context + const executed = await finishLeaseExecution({ summary, signal, save, prepare: async () => { + checkCancellation(signal) const port = await new Promise(resolvePort => { const socket = createServer(); socket.listen(0, '127.0.0.1', () => { const chosen = socket.address().port; socket.close(() => resolvePort(chosen)) }) }) const token = randomUUID(), tokenFile = join(privateDir, 'room.env'); writeFileSync(tokenFile, `PEERTABLE_POST_TOKEN=${token}\n`, { mode: 0o600 }) const serverUrl = `http://127.0.0.1:${port}`, room = `parent-lease-${harness}-${randomUUID()}` - const server = spawn(process.execPath, [join(pkg, 'room/server.mjs')], { cwd: out, env: { ...process.env, PEERTABLE_PORT: String(port), PEERTABLE_DATA: join(privateDir, 'room-data'), PEERTABLE_POST_TOKEN: token }, stdio: ['ignore', 'ignore', 'pipe'] }) + checkCancellation(signal) + server = spawn(process.execPath, [join(pkg, 'room/server.mjs')], { cwd: out, env: { ...process.env, PEERTABLE_PORT: String(port), PEERTABLE_DATA: join(privateDir, 'room-data'), PEERTABLE_POST_TOKEN: token }, stdio: ['ignore', 'ignore', 'pipe'] }) let stderr = ''; server.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-4000) }) - const apiFor = name => async (path, body) => { const response = await fetch(`${serverUrl}/api/${encodeURIComponent(name)}/${path}`, { method: body ? 'POST' : 'GET', headers: { 'Content-Type': 'application/json', 'X-Peertable-Token': token }, body: body ? JSON.stringify(body) : undefined }); if (!response.ok) fail('ACCEPTANCE_LEASE_HTTP', `${path}:${response.status}`); return response.json() } + const apiFor = name => async (path, body) => { const response = await fetch(`${serverUrl}/api/${encodeURIComponent(name)}/${path}`, { method: body ? 'POST' : 'GET', headers: { 'Content-Type': 'application/json', 'X-Peertable-Token': token }, body: body ? JSON.stringify(body) : undefined, signal }); if (!response.ok) fail('ACCEPTANCE_LEASE_HTTP', `${path}:${response.status}`); return response.json() } const sourceMeta = { os: process.platform, harness, surface: 'cli', source_commit: sourceCommit, runtime_digest: digest, package_version: installedVersion, client_version: installedVersion, package_tarball_sha256: tarballSha256, node_version: process.version, runner, room, isolation: { home: 'normal', auth: 'normal', install: '共通npm pack導入物', room: 'lease専用server', fixture: '公式通常CLIの専用project/process' } } - const errors = []; let context - try { - const end = Date.now() + 15000 - while (!(await fetch(`${serverUrl}/api/rooms`).then(response => response.ok, () => false))) { if (server.exitCode !== null || Date.now() >= end) fail('ACCEPTANCE_LEASE_ROOM_START', stderr); await sleep(200) } + await waitUntil('lease専用room起動', async () => { if (server.exitCode !== null || server.signalCode !== null) fail('ACCEPTANCE_LEASE_ROOM_START', stderr); return fetch(`${serverUrl}/api/rooms`, { signal }).then(response => response.ok, error => { checkCancellation(signal); if (error.code || error.cause?.code) return false; throw error }) }, 15000, 200, { signal, timeoutCode: 'ACCEPTANCE_LEASE_ROOM_START' }) await apiFor(room)('members', { name: 'probe', harness: 'codex' }) log('prepare', { room, installed_root: pkg }) - context = await createNativeScenarioContext('lease', { pkg, out: privateDir, tokenFile, serverUrl, sourceMeta, apiFor, model }) + context = await createNativeScenarioContext('lease', { pkg, out: privateDir, tokenFile, serverUrl, sourceMeta, apiFor, model, signal }) log('native_parent_verified', { parent_session: context.session, parent_process: context.caseMeta.parent_process, endpoint_id: context.caseMeta.endpoint_id }) for (const [name, action] of Object.entries(context.actions)) context.actions[name] = async (input, scope) => { log(`${name}:started`); const result = await action(input, scope); log(`${name}:observed`, { expectation: result.expectation }); return result } - const measured = await runScenario('lease', context) + return context + }, execute: context => runScenario('lease', context), cleanup: async () => { + const errors = [], result = { fixtures_closed: !context, room: null } + try { if (context) { await context.close(); result.fixtures_closed = true } } catch (error) { errors.push(error) } + try { if (server) result.room = await closeOwnedChild(server) } catch (error) { errors.push(error) } + if (errors.length) throw Object.assign(new AggregateError(errors, 'lease所有fixture/roomの回収に失敗しました'), { code: 'ACCEPTANCE_LEASE_CLEANUP_FAILED' }) + return result + } }) + if (summary.status === 'passed') { + try { + const measured = executed.value const evidence = buildCase({ meta: context.caseMeta, scenario: 'lease', checks: measured.checks, observations: measured.observations, extra: { trace: measured.trace, page_chars: measured.page_chars, run_id: measured.run_id, boundary_contract: measured.boundaries } }) const evidenceFile = `rag/parent-delivery/live/${evidence.case_id}.json`, file = join(out, 'public', evidenceFile); mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, JSON.stringify(evidence, null, 2)) const record = buildRecord(evidence, evidenceFile), audit = selfAudit(record, evidence); acceptCase(record, audit) summary.cases.push({ ...record, gate_errors: audit.gate_errors }); writeFileSync(join(out, 'records.json'), JSON.stringify({ records: [record] }, null, 2)) - summary.status = 'passed'; log('lease_completed') - } catch (error) { errors.push({ code: error.code ?? 'ACCEPTANCE_LEASE_RUN_FAILED', message: error.message, detail: error.detail }); summary.status = 'failed'; log('lease_failed', errors.at(-1)) } - finally { - if (server.exitCode === null && server.signalCode === null) { - const exited = new Promise(resolveExit => server.once('exit', resolveExit)); server.kill('SIGTERM'); await Promise.race([exited, sleep(10000)]) - if (server.exitCode === null && server.signalCode === null) { errors.push({ code: 'ACCEPTANCE_LEASE_ROOM_ALIVE' }); summary.status = 'failed' } - } - summary.errors = errors; summary.finished_at = new Date().toISOString(); writeFileSync(summaryFile, JSON.stringify(project(summary), null, 2)) - } + log('lease_completed') + } catch (error) { summary.status = 'failed'; summary.cases = []; summary.errors.push({ code: error.code ?? 'ACCEPTANCE_LEASE_EVIDENCE_FAILED', message: error.message }); log('lease_failed', summary.errors.at(-1)) } + } else log('lease_failed', { errors: summary.errors }) + save() return summary + } finally { cancellation.close() } } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const flags = {}; const args = process.argv.slice(2) diff --git a/experiments/parent-product-acceptance/scenarios-lease-stop.mjs b/experiments/parent-product-acceptance/scenarios-lease-stop.mjs new file mode 100644 index 00000000..563a5926 --- /dev/null +++ b/experiments/parent-product-acceptance/scenarios-lease-stop.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node +// 3OS共通の専用run停止入口。Windowsでcontrollerを強制killしない。 +import { readFileSync } from 'node:fs' +import { resolve, join, dirname } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { runnerProvenance } from './runner-provenance.mjs' +import { requestLeaseStop } from './scenarios-cancellation.mjs' + +export async function stopLeaseOnly({ out, runnerCommit }) { + const runner = runnerProvenance(dirname(fileURLToPath(import.meta.url)), runnerCommit) + const descriptor = JSON.parse(readFileSync(join(resolve(out), 'private/lease-control.json'), 'utf8')) + const platform = await import(pathToFileURL(join(descriptor.pkg, 'skill/scripts/parent-platform.mjs')).href) + return requestLeaseStop({ out, runner, platform }) +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const args = process.argv.slice(2), flags = {} + for (let index = 0; index < args.length; index += 2) { if (!['--out', '--runner-commit'].includes(args[index]) || !args[index + 1] || args[index + 1].startsWith('--')) throw Object.assign(new Error('停止入口には--out/--runner-commitが必要です'), { code: 'ACCEPTANCE_LEASE_STOP_ARGUMENT' }); flags[args[index].slice(2)] = args[index + 1] } + if (!flags.out || !flags['runner-commit']) throw Object.assign(new Error('停止入口には--out/--runner-commitが必要です'), { code: 'ACCEPTANCE_LEASE_STOP_ARGUMENT' }) + process.stdout.write(JSON.stringify(await stopLeaseOnly({ out: flags.out, runnerCommit: flags['runner-commit'] })) + '\n') +} diff --git a/experiments/parent-product-acceptance/scenarios-native.mjs b/experiments/parent-product-acceptance/scenarios-native.mjs index cce40e7c..2b016c4b 100644 --- a/experiments/parent-product-acceptance/scenarios-native.mjs +++ b/experiments/parent-product-acceptance/scenarios-native.mjs @@ -10,6 +10,7 @@ import { createNativeFixtureFactory, waitOwnedFixtureExit } from './scenarios-fi import { sha256 } from './evidence.mjs' import { createBackgroundSurfaceAdapters, nativeInvocation } from './scenarios-surfaces.mjs' import { installQueueProbeFault, queueProbeReceiverProof, watchQueueProbeState, queueProbeTimeoutProof, codexProbeJoinBoundary, installQueueConnectionObserver, observedQueueConnections, QUEUE_PROBE_OBSERVATION_LIMIT_MS } from './scenarios-queue-fault.mjs' +import { checkCancellation } from './scenarios-cancellation.mjs' import { installReceiverPreparationFault, codexReceiverFailureBoundary, receiverPreparationFailure } from './scenarios-receiver-fault.mjs' const fail = (code, message, detail) => { throw Object.assign(new Error(message), { code, detail }) } @@ -884,6 +885,7 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { // rootの既存runnerへ接続する入口。scenarioごとに新CIDと専用artifactを作り、証拠を使い回さない。 export async function createNativeScenarioContext(name, options) { + checkCancellation(options.signal) const surfaceAdapters = { ...(['cursor', 'grok'].includes(options.sourceMeta.harness) ? await createBackgroundSurfaceAdapters(options) : {}), ...options.surfaceAdapters } const factory = await createNativeFixtureFactory({ ...options, surfaceAdapters }) let primary @@ -896,14 +898,17 @@ export async function createNativeScenarioContext(name, options) { if (['claim_race', 'slot_race'].includes(name)) await fixture.addProductCompetitors({ count: 2 }) if (name === 'compatibility_hooks') await fixture.addProductCompetitors({ count: 1, compatibilityHarness: options.sourceMeta.harness === 'claude' ? 'codex' : 'claude' }) } }) + checkCancellation(options.signal) const native = createNativeActions({ factory, primary, lifecycle: options.lifecycle }) const context = await createScenarioContext({ ...options, ...primary.target, projectDir: primary.project, privateDir: primary.directory, nativeActions: native.actions }) context.registerEndpoint('self', primary.target) const ordinaryFinalize = context.finalize // native finalizeには同じ実境界の所有helpersを渡す。判定をcallbackから受け取らない。 const platform = await import(pathToFileURL(join(options.pkg, 'skill/scripts/parent-platform.mjs')).href) - context.finalize = async scope => { const errors = []; try { await ordinaryFinalize(scope) } catch (error) { errors.push(error) }; try { await native.finalize({ ...scope, context, ...platform }) } catch (error) { errors.push(error) }; if (errors.length) throw new AggregateError(errors, 'scenario所有process/設定の後片付けに失敗しました') } + let finalization + context.finalize = scope => finalization ??= (async () => { const errors = []; try { await ordinaryFinalize(scope) } catch (error) { errors.push(error) }; try { await native.finalize({ ...scope, context, ...platform }) } catch (error) { errors.push(error) }; if (errors.length) throw Object.assign(new AggregateError(errors, 'scenario所有process/設定の後片付けに失敗しました'), { code: 'ACCEPTANCE_NATIVE_CLEANUP_FAILED' }); return { fixtures_closed: true } })() + context.close = () => context.finalize({ runId: 'pre-scenario-cleanup', checks: [], scenario: name }) context.caseMeta = primary.target.meta return context - } catch (error) { try { await factory.close() } catch (cleanup) { throw new AggregateError([error, cleanup], 'fixture準備と後片付けに失敗しました') }; throw error } + } catch (error) { try { await factory.close(); error.cleanup = { status: 'passed', fixtures_closed: true } } catch (cleanup) { throw Object.assign(new AggregateError([error, cleanup], 'fixture準備と後片付けに失敗しました'), { code: 'ACCEPTANCE_NATIVE_CLEANUP_FAILED', cleanup: { status: 'failed', fixtures_closed: false } }) }; throw error } } diff --git a/experiments/parent-product-acceptance/scenarios.mjs b/experiments/parent-product-acceptance/scenarios.mjs index 0790ba3e..45cec661 100644 --- a/experiments/parent-product-acceptance/scenarios.mjs +++ b/experiments/parent-product-acceptance/scenarios.mjs @@ -1,6 +1,7 @@ // 実機受入24scenarioの手順。公式親の会話と実境界の観測が揃うまで合格を作らない。 import { randomUUID } from 'node:crypto' import { isDeepStrictEqual } from 'node:util' +import { checkCancellation } from './scenarios-cancellation.mjs' import { scenarios as inventory } from '../../scripts/parent-delivery-acceptance-contract.mjs' export const scenarioNames = Object.keys(inventory).filter(name => name !== 'audience') @@ -73,19 +74,22 @@ export function validateNativeCheck(check, { scenario, runId, session }) { // contextは導入物・実親・専用roomの操作を所有する。action未提供時は実行前にtyped errorで止める。 export async function runScenario(name, context) { const { harness, session, pageChars, runId = randomUUID() } = context + const checks = [], observations = [], trace = [], nonces = new Set() + const scope = { scenario: name, runId, session, harness, pageChars, checks, observations, signal: context.signal, boundaries: receiverBoundaries[harness] } + try { + checkCancellation(context.signal) if (!receiverBoundaries[harness]) fail('ACCEPTANCE_HARNESS_UNSUPPORTED', harness) const plan = scenarioPlan(name, { pageChars, harness }) const missing = [...new Set(plan.map(item => item.action))].filter(action => typeof context.actions?.[action] !== 'function') if (missing.length) fail('ACCEPTANCE_SCENARIO_ADAPTER_MISSING', `${harness}/${name}: ${missing.join(', ')}`) - const checks = [], observations = [], trace = [], nonces = new Set() - const scope = { scenario: name, runId, session, harness, pageChars, checks, observations, boundaries: receiverBoundaries[harness] } - try { for (const [index, item] of plan.entries()) { + checkCancellation(context.signal) scope.step_index = index const nonce = `PEERTABLE_${name.toUpperCase()}_${index}_${randomUUID()}` const body = item.input.body ?? `日本語の観測\n「引用」😀 & 字面>\n配送確認の符号は ${nonce} です。` const input = { ...item.input, ...(item.action === 'deliver' ? { nonce, body: `${body}${item.input.long ? ('\n長文 日本語 < > & 字面& 😀'.repeat(Math.ceil(pageChars / 20) * 3)) : ''}\n${nonce}` } : {}), index } const result = await context.actions[item.action](input, scope) + checkCancellation(context.signal) if (item.action === 'deliver' && !item.input.defer && !item.input.allowRetained) { const targetSession = result.parent_session ?? session validateNativeCheck(result.check, { scenario: name, runId, session: targetSession }) From 6689b32164f26d32ec2212159074e1cd9369e42d Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:06:19 +0900 Subject: [PATCH 71/72] =?UTF-8?q?Cursor=E3=81=AE=E8=A6=AA=E5=8F=97?= =?UTF-8?q?=E4=BF=A1hook=E3=82=92Aiterm=E3=81=AB=E6=8F=83=E3=81=88Windows?= =?UTF-8?q?=E6=9C=AC=E4=BA=BA=E7=A2=BA=E8=AA=8D=E3=82=92=E6=A8=99=E6=BA=96?= =?UTF-8?q?API=E3=81=B8=E9=81=A9=E5=90=88=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/current-design.md | 2 + docs/plan_parent-native-delivery.md | 8 +- .../scenarios-context.mjs | 7 +- .../scenarios-fixtures.mjs | 4 +- .../scenarios-native.mjs | 7 +- .../scenarios-native.test.mjs | 15 +-- .../scenarios.test.mjs | 6 +- rag/INDEX.md | 5 + rag/parent-delivery/cursor-aiterm-parity.md | 17 ++++ .../linux-grok-probe-repair-diagnosis.json | 79 ++++++++++++++++ .../windows-lock-contention-diagnosis.json | 2 +- .../windows-process-identity-diagnosis.json | 91 +++++++++++++++++++ scripts/run-product-ci.mjs | 2 +- skill/scripts/parent-connect.mjs | 2 +- skill/scripts/parent-delivery.test.mjs | 12 ++- skill/scripts/parent-hook.mjs | 2 +- skill/scripts/parent-platform.mjs | 3 +- skill/scripts/parent-receivers/cursor.mjs | 5 +- skill/scripts/parent-windows-process.mjs | 61 +++++++++++++ skill/scripts/parent-windows-process.test.mjs | 51 +++++++++++ 20 files changed, 355 insertions(+), 26 deletions(-) create mode 100644 rag/parent-delivery/cursor-aiterm-parity.md create mode 100644 rag/parent-delivery/linux-grok-probe-repair-diagnosis.json create mode 100644 rag/parent-delivery/windows-process-identity-diagnosis.json create mode 100644 skill/scripts/parent-windows-process.mjs create mode 100644 skill/scripts/parent-windows-process.test.mjs diff --git a/docs/current-design.md b/docs/current-design.md index 56fba84d..5918619d 100644 --- a/docs/current-design.md +++ b/docs/current-design.md @@ -63,6 +63,8 @@ Web UIのメンバーカードは**オーナー意匠**である(裁定 2026-0 Peertableは原文spoolと受信cursor、Claudeの公式asyncRewake、Codexの公式queueと同期hook、Cursorの公式hookとnative背景Shell、Grokのnative背景完了と公開出力回収を所有する。ユーザー領域の`~/.peertable/parent-receivers/`は接続・実会話相関・共有slotを、projectの`.team/parent-delivery/`は配送本文と進行を持つ。room台帳は宛先別receiptと親のhealthの正本である。 +Cursorの本文差し込みはAitermの受信handlerと同じ`postToolUse`/`postToolUseFailure`の`additional_context`を使う。`afterMCPExecution`は実会話との束縛に使い、本文を消費しない。idle中は公式の背景Shellで受け取り、hookとの共通claimで重複を防ぐ。room購読・宛先・連続受信の状態はPeertableが所有する。 + 耳疎通の`verified`と現在の受信継続は別に確認する。runtimeは`armed`、`rearm_pending`、`stopped`、`failed`を区別し、Cursor/Grokはreceiptの完成済みnative tool入力を登録して次の受信を維持する。故障は宛先別receipt、health、診断へ原因code付きで出す。旧parent_watchは移行診断の対象である。 受付不明は`unknown`として原文と受付証拠を保持し、自動再送しない。Codexのqueue受付ID・時刻は後続receiptでも保持する。長文は同じ配送ID・digestへ束縛した継続tokenで最後まで読み、最後の出力完了までackしない。 diff --git a/docs/plan_parent-native-delivery.md b/docs/plan_parent-native-delivery.md index dc696864..2b9f1743 100644 --- a/docs/plan_parent-native-delivery.md +++ b/docs/plan_parent-native-delivery.md @@ -392,7 +392,7 @@ Codexの公式hook本文はXMLのtextとして記録される。[一次仕様の macOSの短命process終了時の成功・空出力の誤分類と、Linux Grokの公式更新先名の本人認識を修理した。さらにCodexの初回probeに期限が設定されない欠陥、期限判定がroom心拍を待って遅れる欠陥を実OS障害で確認し、初回armとwatcher自身のtimerを修理した。[CLI修理記録](../rag/parent-delivery/cli-lifecycle-repairs.md)に実測、未観測の境界、指紋、focused確認を保存する。これらの準備診断を正式配送の合格へ算入しない。 -controllerはCursorの公式postToolUseが確定した入力全体を照合する。Windows Grokの公式罫線内の空promptは原画面fixtureで判定する。Claudeのhook無効化は同じ実会話を公式CLIの自己run設定でresumeし、共有設定を保持して無効化・復元の実因果を測定する。controllerのfocused合格だけで正式受入を埋めない。 +controllerはCursorのモデルAPIへ渡した完成済み入力全体と、公式postToolUseが正規化したhook入力を別々に照合する。Windows Grokの公式罫線内の空promptは原画面fixtureで判定する。Claudeのhook無効化は同じ実会話を公式CLIの自己run設定でresumeし、共有設定を保持して無効化・復元の実因果を測定する。controllerのfocused合格だけで正式受入を埋めない。 現行manifestの正式受入は再測定中で、長時間leaseの合格もまだ無い。Linux Cursorは本人ログイン待ち、Desktop/IDEはJevの画面取得の解決または操作ツール変更の回答待ちである。新候補の3 OS製品CI、全必須面の実機受入、main着地、npm公開・registry導入・本番反映を順に完了する。 @@ -411,3 +411,9 @@ Codexの`binding_deadline`前半は、[公式callerの比較](../rag/parent-deli [Cursorの入力診断](../rag/parent-delivery/cursor-native-input-diagnosis.json)で、完成済みreceiptがhook向けの`cwd`をモデルAPIへ渡し、モデルが`working_directory`と背景待機引数へ補正する欠陥を確認した。モデル向け入力を製品で完成させ、実hook入力を別保存する。controllerでもモデル入力とhook入力を個別照合してから新sourceを実機測定する。Desktopへ同じschemaが通ることはまだ確認していない。 [Windowsの排他再現](../rag/parent-delivery/windows-lock-contention-diagnosis.json)では6実processの同時更新で実10秒期限の失敗が1件発生し、失敗workerの待ち時間の99.1%がCIM本人確認だった。本人確認を標準OS APIへ適合する案を検証する。期限と共通Bakery排他は維持する。設定のうち`cli-config.json`は比較元bytesが無く、変更keyと復元を確認できなかった。正式合格に数えず、この限界を保持する。 + +[120秒期限のLinux Grok診断](../rag/parent-delivery/linux-grok-probe-repair-diagnosis.json)では初回armから33.102秒で確認し、実期限内に失敗を発生させず回収後のモデル発言に原文符号が出た。room健康状態もup/armed/verifiedへ戻った。診断であり、次の製品修理後の正式受入へ流用しない。Windows Cursorのverifiedだけでは、保存されたnative本文到達を証明できなかったため未確認を保持する。 + +[AitermのCursor受信実装との対照](../rag/parent-delivery/cursor-aiterm-parity.md)に従い、本文を`postToolUse`/`postToolUseFailure`だけへ出し、`afterMCPExecution`は束縛だけに揃えた。Aitermのhandlerは失敗後hookにも対応するが、参照時点のsetupは成功後hookだけを登録している。Peertableは本計画の失敗後hook契約も登録する。Aiterm本体は変更しない。 + +[Windows本人確認APIの実測](../rag/parent-delivery/windows-process-identity-diagnosis.json)では、既存CIM形式とnative APIが463件すべて一致し、終了processと実PID再利用も区別できた。同じhandleで開始時刻と存命を読む実装を採用し、親process・実行file・commandの取得は従来のCIMに残す。排他の仕組み・期限・永続identityは変えない。修理後配布物の6process競合と実Cursor受信は次の確認とする。 diff --git a/experiments/parent-product-acceptance/scenarios-context.mjs b/experiments/parent-product-acceptance/scenarios-context.mjs index 3d526ac1..c45406a4 100644 --- a/experiments/parent-product-acceptance/scenarios-context.mjs +++ b/experiments/parent-product-acceptance/scenarios-context.mjs @@ -131,12 +131,12 @@ export function assertCodexBusy({ workTurn, delivery, record, turns }) { return native } -// Cursor Shellの完成済み入力はpostToolUseが渡す全体を使う。model入力は別の原証拠として保持する。 +// 完成済み入力はモデルAPIへ渡した全体。hookが正規化した入力とは分けて照合する。 export function completedNativeInput(harness, use) { - return harness === 'cursor' ? use.hook_input : use.input + return use.input } export function completedNativeInputProof(harness, use) { - return { completed_input: completedNativeInput(harness, use), model_input: use.input, hook_input: use.hook_input, official_stage: harness === 'cursor' ? 'postToolUse' : 'native_tool', tool_use_id: use.id, session: use.session, turn_id: use.turn_id } + return { completed_input: completedNativeInput(harness, use), model_input: use.input, hook_input: use.hook_input, official_stage: 'native_tool', hook_stage: harness === 'cursor' ? 'postToolUse' : null, tool_use_id: use.id, session: use.session, turn_id: use.turn_id } } // Shellという名前だけでは許可しない。製品が確定した入力と実task/PIDの相関を要求する。 @@ -149,6 +149,7 @@ export function nativeReceiveToolAllowed(use, registrations, session) { const pid = use.native_task_pid ?? output?.pid return item.owner_verified === true && item.parent_session === session && (use.session ?? use.parent_session) === session && use.name === task.input?.name && isDeepStrictEqual(completedNativeInput(task.input?.name === 'Shell' ? 'cursor' : 'grok', use), task.input?.input) + && (task.input?.name !== 'Shell' || (task.hook_input && isDeepStrictEqual(use.hook_input, task.hook_input))) && String(taskId ?? '') === task.id && pid === task.pid && item.waiter_owner.pid > 0 && item.waiter_owner.started && task.process_identity?.pid === pid && task.process_identity.started }) diff --git a/experiments/parent-product-acceptance/scenarios-fixtures.mjs b/experiments/parent-product-acceptance/scenarios-fixtures.mjs index 657b94a6..d3a91f09 100644 --- a/experiments/parent-product-acceptance/scenarios-fixtures.mjs +++ b/experiments/parent-product-acceptance/scenarios-fixtures.mjs @@ -253,7 +253,7 @@ export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUr const command = platform.hookCommand(process.execPath, [observer, observations, controls, ...additionalArgs]) if (!['claude', 'codex'].includes(harness) && !adapter.projectHookFile) fail('ACCEPTANCE_PROJECT_HOOK_PATH_UNCONFIRMED', `${harness}: 公式のproject hook配置が実測されていません`) const file = harness === 'claude' ? join(project, '.claude/settings.local.json') : harness === 'codex' ? join(project, '.codex/hooks.json') : appendToProductFile && harness === 'grok' ? join(project, '.grok/hooks/peertable-parent.json') : adapter.projectHookFile(project) - const nativeEvents = events ?? (harness === 'cursor' ? ['preToolUse', 'postToolUse', 'afterMCPExecution'] : ['PreToolUse', 'PostToolUse', 'Stop']) + const nativeEvents = events ?? (harness === 'cursor' ? ['preToolUse', 'postToolUse', 'postToolUseFailure', 'afterMCPExecution'] : ['PreToolUse', 'PostToolUse', 'Stop']) fixture.modify(file, before => { const value = before ? JSON.parse(before.toString('utf8').replace(/^\uFEFF/u, '')) : { ...(harness === 'cursor' ? { version: 1 } : {}), hooks: {} } for (const event of nativeEvents) { @@ -276,7 +276,7 @@ export async function createNativeFixtureFactory({ pkg, out, tokenFile, serverUr // stdinは公式hook eventそのもの。製品のstdout/stderrを無加工で同じ親へ返す。 writeFileSync(entry, `import {spawn} from 'node:child_process';import {appendFileSync} from 'node:fs';let raw='';for await(const c of process.stdin)raw+=c;const e=JSON.parse(raw.replace(/^\\uFEFF/u,''));const log=x=>appendFileSync(process.argv[2],JSON.stringify({at:new Date().toISOString(),pid:process.pid,parent:process.ppid,event:e,...x})+'\\n');log({phase:'started'});const child=spawn(process.execPath,[process.argv[3],process.argv[4]],{stdio:['pipe','pipe','pipe']});let stdout='',stderr='';child.stdout.on('data',c=>{stdout+=c;process.stdout.write(c)});child.stderr.on('data',c=>{stderr+=c;process.stderr.write(c)});child.stdin.end(raw);child.on('exit',(code,signal)=>{log({phase:'completed',child_pid:child.pid,code,signal,stdout,stderr});process.exitCode=code??1});\n`, { mode: 0o600 }) const commands = Array.from({ length: count }, (_, index) => platform.hookCommand(process.execPath, [entry, rows, join(pkg, 'skill/scripts/parent-hook.mjs'), compatibilityHarness ?? harness, String(index)])) - const events = harness === 'cursor' ? ['postToolUse', 'afterMCPExecution'] : ['PostToolUse', 'Stop'] + const events = harness === 'cursor' ? ['postToolUse', 'postToolUseFailure', 'afterMCPExecution'] : ['PostToolUse', 'Stop'] fixture.modify(file, before => { const value = before ? JSON.parse(before.toString('utf8').replace(/^\uFEFF/u, '')) : { ...(harness === 'cursor' ? { version: 1 } : {}), hooks: {} } for (const event of events) { diff --git a/experiments/parent-product-acceptance/scenarios-native.mjs b/experiments/parent-product-acceptance/scenarios-native.mjs index 2b016c4b..e81ee075 100644 --- a/experiments/parent-product-acceptance/scenarios-native.mjs +++ b/experiments/parent-product-acceptance/scenarios-native.mjs @@ -209,7 +209,8 @@ export function nativeLeaseRegistration(target, { sameProcess, processDescendsFr const native = waiter.native_task if (!native || !sameProcess(native.process_identity) || !processDescendsFrom(waiter.owner, native.process_identity)) return null const seen = target.observe(), expectedName = target.meta.harness === 'cursor' ? 'Shell' : 'run_terminal_command' - const use = seen.toolUses.find(use => use.name === expectedName && use.session === target.meta.parent_session && isDeepStrictEqual(completedNativeInput(target.meta.harness, use), native.input?.input)) + const use = seen.toolUses.find(use => use.name === expectedName && use.session === target.meta.parent_session && isDeepStrictEqual(completedNativeInput(target.meta.harness, use), native.input?.input) + && (target.meta.harness !== 'cursor' || (native.hook_input && isDeepStrictEqual(use.hook_input, native.hook_input)))) const task = use && seen.tasks?.find(task => task.tool_use_id === use.id && String(task.task_id ?? task.id) === native.id && task.pid === native.pid) if (!use?.turn_id || !task || native.input?.name !== expectedName) return null return { state: current, waiter, registration: { tool_use_id: use.id, turn_id: use.turn_id, task_id: native.id, input: completedNativeInput(target.meta.harness, use), input_proof: completedNativeInputProof(target.meta.harness, use), owner: native.process_identity, native_task: task } } @@ -293,7 +294,7 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { writeFileSync(script, `import {writeFileSync,existsSync} from 'node:fs';writeFileSync(process.argv[2],JSON.stringify({pid:process.pid}));while(!existsSync(process.argv[3]))await new Promise(r=>setTimeout(r,100));writeFileSync(process.argv[4],JSON.stringify({pid:process.pid}));console.log(process.argv[5]);\n`) const command = s.shellCommand(process.execPath, [script, identity, release, completed, nonce]), before = target.observe().rows state(s).work = { fixture, identity, release, completed, nonce, before } - const input = s.harness === 'cursor' ? { command, cwd: fixture.project } : { command, description: '配送中も継続する専用受入作業', background: true, timeout: 0 } + const input = s.harness === 'cursor' ? { command, working_directory: fixture.project, block_until_ms: 0, description: '配送中も継続する専用受入作業' } : { command, description: '配送中も継続する専用受入作業', background: true, timeout: 0 } await fixture.submit(`作業継続の実機受入です。${s.harness === 'cursor' ? 'Shell' : 'run_terminal_command'}へ次の完成済み入力を渡してください。専用processを停止せず、room符号の報告後も作業を維持してください。runnerが解除した後に公式taskの全量出力を読んで、完了符号${nonce}を報告してください。input: ${JSON.stringify(input)}`) const observed = await s.until('専用作業processと公式taskの相関', () => { if (!existsSync(identity)) return null @@ -400,7 +401,7 @@ export function createNativeActions({ factory, primary, lifecycle = null }) { const beforeEvents = fixture.adapter.hookEvents().length await fixture.stop(); await fixture.launch() const marker = `NEW_SESSION_${randomUUID()}`, command = s.shellCommand(process.execPath, ['-e', `console.log(${JSON.stringify(marker)})`]) - const input = s.harness === 'cursor' ? { command, cwd: fixture.project } : { command, description: '専用新会話の実相関確認', background: false, timeout: 0 } + const input = s.harness === 'cursor' ? { command, working_directory: fixture.project, description: '専用新会話の実相関確認' } : { command, description: '専用新会話の実相関確認', background: false, timeout: 0 } await fixture.submit(`新会話の実相関試験です。${s.harness === 'cursor' ? 'Shell' : 'run_terminal_command'}へ次の入力を渡し、出力をそのまま報告してください。Peertable joinはまだ呼ばないでください。input: ${JSON.stringify(input)}`) const event = await s.until('新CLIの実hook CID', () => fixture.adapter.hookEvents().slice(beforeEvents).find(row => nativeSession(row.event) && nativeSession(row.event) !== old.meta.parent_session && JSON.stringify(row.event).includes(marker)), 120000, 100) const newSession = nativeSession(event.event), file = await s.until('新会話の公式transcript', () => fixture.adapter.transcript(newSession), 30000, 100) diff --git a/experiments/parent-product-acceptance/scenarios-native.test.mjs b/experiments/parent-product-acceptance/scenarios-native.test.mjs index b5ef45c2..f1f5e9b0 100644 --- a/experiments/parent-product-acceptance/scenarios-native.test.mjs +++ b/experiments/parent-product-acceptance/scenarios-native.test.mjs @@ -158,8 +158,8 @@ test('実processで独立receiverを停止し、親が先に終了しても再 test('lease開始はverified後の未登録期間を待ち、公式tool/task登録と本人を照合する', () => { - const owner = { pid: 200, started: 'own開始' }, input = { command: '完成済み', cwd: '/専用' }, native = { id: 'own-task', pid: owner.pid, process_identity: owner, input: { name: 'Shell', input } } - const current = { state: 'verified', runtime: 'rearm_pending', waiter: null }, use = { id: 'use', turn_id: 'turn', name: 'Shell', session: 'own', input: { command: input.command }, hook_input: input }, task = { id: 'own-task', tool_use_id: 'use', pid: owner.pid } + const owner = { pid: 200, started: 'own開始' }, input = { command: '完成済み', working_directory: '/専用', block_until_ms: 0, description: '親受信' }, hookInput = { command: input.command, cwd: '/専用' }, native = { id: 'own-task', pid: owner.pid, process_identity: owner, input: { name: 'Shell', input }, hook_input: hookInput } + const current = { state: 'verified', runtime: 'rearm_pending', waiter: null }, use = { id: 'use', turn_id: 'turn', name: 'Shell', session: 'own', input, hook_input: hookInput }, task = { id: 'own-task', tool_use_id: 'use', pid: owner.pid } const target = { spool: { read: () => current }, meta: { harness: 'cursor', parent_session: 'own' }, observe: () => ({ toolUses: [use], tasks: [task] }) } const options = { sameProcess: () => true, processDescendsFrom: () => true } assert.equal(nativeLeaseRegistration(target, options), null) @@ -167,11 +167,14 @@ test('lease開始はverified後の未登録期間を待ち、公式tool/task登 assert.equal(nativeLeaseRegistration(target, options).registration.task_id, 'own-task') const proof = nativeLeaseRegistration(target, options).registration assert.deepEqual(proof.input, input) - assert.deepEqual(proof.input_proof.model_input, { command: input.command }) - assert.equal(proof.input_proof.official_stage, 'postToolUse') + assert.deepEqual(proof.input_proof.model_input, input) + use.input = { command: input.command, cwd: '/専用' }; assert.equal(nativeLeaseRegistration(target, options), null) + use.input = input + assert.equal(proof.input_proof.official_stage, 'native_tool') + assert.equal(proof.input_proof.hook_stage, 'postToolUse') use.hook_input = { ...input, cwd: '/別project' }; assert.equal(nativeLeaseRegistration(target, options), null) use.hook_input = undefined; assert.equal(nativeLeaseRegistration(target, options), null) - use.hook_input = input + use.hook_input = hookInput use.session = '他会話'; assert.equal(nativeLeaseRegistration(target, options), null) use.session = 'own'; task.pid = 999; assert.equal(nativeLeaseRegistration(target, options), null) current.state = 'failed'; current.error_code = '実製品error' @@ -242,7 +245,7 @@ test('lease armのmodule path結合はendpoint再joinを呼ばず4harnessで文 for (const harness of ['claude', 'codex', 'cursor', 'grok']) { let rejoined = 0; const fixture = { harness, adapter: {}, queueObserver: { events: () => [], close: async () => {} }, join: async () => { rejoined++ } } const nativeName = harness === 'cursor' ? 'Shell' : 'run_terminal_command', input = { command: '専用登録入力' } - const state = { state: 'verified', runtime: 'armed', endpoint_id: 'own', watcher: owner, caller: { owner }, waiter: { owner, native_task: { id: 'task', pid: owner.pid, process_identity: owner, input: { name: nativeName, input } } } } + const state = { state: 'verified', runtime: 'armed', endpoint_id: 'own', watcher: owner, caller: { owner }, waiter: { owner, native_task: { id: 'task', pid: owner.pid, process_identity: owner, input: { name: nativeName, input }, hook_input: harness === 'cursor' ? input : undefined } } } const target = { fixture, meta: { harness, parent_session: 'own' }, spool: { id: 'own', read: () => state }, observe: () => ({ toolUses: [{ name: nativeName, session: 'own', input, hook_input: harness === 'cursor' ? input : undefined, id: 'tool', turn_id: 'turn' }], tasks: [{ tool_use_id: 'tool', id: 'task', pid: owner.pid }] }) } const native = createNativeActions({ factory: { close: async () => {} }, primary: fixture }) const scope = { harness, runId: harness, pkg: process.cwd(), checks: [], context: { endpoint: () => target }, artifactFor: () => join(dir, harness + '.json'), processIdentity, sameProcess, processDescendsFrom, until: async (_, probe) => { const value = await probe(); assert.ok(value); return value }, result: (_, expectation, kind, detail) => ({ expectation, kind, ...detail }) } diff --git a/experiments/parent-product-acceptance/scenarios.test.mjs b/experiments/parent-product-acceptance/scenarios.test.mjs index 61c8f76b..fc26791a 100644 --- a/experiments/parent-product-acceptance/scenarios.test.mjs +++ b/experiments/parent-product-acceptance/scenarios.test.mjs @@ -154,9 +154,11 @@ test('Codex idleは過去のtask_completeを別turnへ流用しない', t => { }) test('受信維持Shellは完成済み入力・実task ID・PID・所有相関が全て一致した場合だけ許可する', () => { - const registration = { parent_session: 's', owner_verified: true, waiter_owner: { pid: 10, started: 'waiter-start' }, native_task: { id: 'task', pid: 20, process_identity: { pid: 20, started: 'task-start' }, input: { name: 'Shell', input: { command: 'exact-entry args', cwd: '/fixture' } } } } - const use = { name: 'Shell', session: 's', input: { command: 'exact-entry args' }, hook_input: { command: 'exact-entry args', cwd: '/fixture' }, output: { shell_id: 'task', pid: 20 } } + const registration = { parent_session: 's', owner_verified: true, waiter_owner: { pid: 10, started: 'waiter-start' }, native_task: { id: 'task', pid: 20, process_identity: { pid: 20, started: 'task-start' }, input: { name: 'Shell', input: { command: 'exact-entry args', working_directory: '/fixture', block_until_ms: 0, description: '親受信' } }, hook_input: { command: 'exact-entry args', cwd: '/fixture' } } } + const use = { name: 'Shell', session: 's', input: structuredClone(registration.native_task.input.input), hook_input: { command: 'exact-entry args', cwd: '/fixture' }, output: { shell_id: 'task', pid: 20 } } assert.equal(nativeReceiveToolAllowed(use, [registration], 's'), true) + assert.equal(nativeReceiveToolAllowed({ ...use, input: { command: 'exact-entry args', cwd: '/fixture' } }, [registration], 's'), false) + assert.equal(nativeReceiveToolAllowed({ ...use, input: { ...use.input, block_until_ms: 1000 } }, [registration], 's'), false) assert.equal(nativeReceiveToolAllowed({ ...use, hook_input: { command: 'unrelated-work', cwd: '/fixture' } }, [registration], 's'), false) assert.equal(nativeReceiveToolAllowed({ ...use, hook_input: { command: 'exact-entry args', cwd: '/別project' } }, [registration], 's'), false) assert.equal(nativeReceiveToolAllowed({ ...use, hook_input: undefined }, [registration], 's'), false) diff --git a/rag/INDEX.md b/rag/INDEX.md index 3542c44e..1203f043 100644 --- a/rag/INDEX.md +++ b/rag/INDEX.md @@ -18,3 +18,8 @@ - [正常なLinux Grokのprobe遅延](parent-delivery/linux-grok-probe-latency-diagnosis.json) — 正規本文回収53.198秒と健康復旧の原記録、共通期限の調整根拠。 - [Codex同会話再登録の修理診断](parent-delivery/codex-probe-rejoin-repair-diagnosis.json) — 新probe原文・後続DM・health復旧と旧failed保持の独立照合。正式受入へ算入しない。 +- [Cursor入力の実診断](parent-delivery/cursor-native-input-diagnosis.json) — モデルAPIの完成済み入力とhookが正規化した入力の差。 +- [AitermとCursor受信の対照](parent-delivery/cursor-aiterm-parity.md) — 束縛hookと本文差し込みhook、背景受信、Peertableが所有するroom状態の区別。 +- [Windows排他の実再現](parent-delivery/windows-lock-contention-diagnosis.json) — 6process同時更新でCIM本人確認が10秒期限を占めた原記録。 +- [Windows本人確認APIの実測](parent-delivery/windows-process-identity-diagnosis.json) — native handleの存命・開始時刻とCIM互換性、実PID再利用、計測の限界。 +- [120秒期限のLinux Grok診断](parent-delivery/linux-grok-probe-repair-diagnosis.json) — 正規本文回収33.102秒とhealth復旧。次のsourceの正式受入には算入しない。 diff --git a/rag/parent-delivery/cursor-aiterm-parity.md b/rag/parent-delivery/cursor-aiterm-parity.md new file mode 100644 index 00000000..370cf54b --- /dev/null +++ b/rag/parent-delivery/cursor-aiterm-parity.md @@ -0,0 +1,17 @@ +# Cursor受信とAitermの対照 + +出典: Aiterm `8cfe1da43a28606ccb53acd1dbfcc0607436add0` の `src/cursor-parent-receiver.ts`、`src/cursor-parent-receive.ts`、`src/setup-integrations.ts`、`src/core.ts`。確認日: 2026-09-29。確度: 実sourceの静的照合。Peertableの実機受信の証明とは分ける。 + +| 責務 | Aiterm | Peertable | +| --- | --- | --- | +| 会話相関 | MCP結果の配送IDをafterMCPExecution/postToolUseのconversation_idへ束縛 | join結果のendpoint_idを同じ公式hookのconversation_idへ束縛 | +| 作業中の本文 | postToolUse/postToolUseFailureのadditional_context | 同じ2種のhookのadditional_context | +| idle中の本文 | receiptの受信processを親の背景toolから起動 | receiptの受信processを公式の背景Shellから起動 | +| 二重受信の排他 | hookとreceiverの共通claim | hookとreceiverの共通spool claim | +| 継続受信 | 子の回答1件ごとのreceiver | roomのDM・複数宛・allを保存し、次の背景受信を登録 | + +修理前のPeertableはafterMCPExecutionでも本文を消費し、postToolUseFailureを登録していなかった。Aitermのhandlerと同じ注入口に揃えた。Aitermのsetupは参照時点でafterMCPExecution/postToolUseだけを登録しているため、「Aitermの既定設定でも失敗後hookが発火済み」とは扱わない。Peertableでは合意済みの失敗後hook対応を接続設定へ含めた。 + +背景ShellのモデルAPI入力はworking_directoryとblock_until_ms: 0を含む完成済み入力、公式hookはcommandとcwdへ正規化された入力を持つ。両者を分けて保存・照合し、モデルによる引数補正を必要としない。根拠は[実入力の診断](cursor-native-input-diagnosis.json)。Desktopの同一schemaは未確認でありCLIの記録だけでは成立としない。 + +roomの購読cursor・宛先別receipt・再登録・長文回収はPeertableが所有する。Aitermの配送へPeertable専用分岐を追加しない。 diff --git a/rag/parent-delivery/linux-grok-probe-repair-diagnosis.json b/rag/parent-delivery/linux-grok-probe-repair-diagnosis.json new file mode 100644 index 00000000..8a5bb822 --- /dev/null +++ b/rag/parent-delivery/linux-grok-probe-repair-diagnosis.json @@ -0,0 +1,79 @@ +{ + "schema": "peertable.grok-probe-repair-diagnosis.v1", + "source_commit": "980cbebc1acab7461daa064abec8814b410f0137", + "runtime_digest": "19cceff45965f64a0733b9bc8f207fca58a2295b8d0c54ad1e0deb72f573c5b1", + "formal_acceptance_credit": 0, + "root_raw_audit": true, + "observed_surface": "Linux Grok通常CLI", + "first_armed": { + "at": "2026-09-28T21:58:21.201Z", + "mono_ms": 55567, + "kind": "FIRST_ARMED", + "probe_deadline": 1790632821201, + "native_task": { + "id": "01a0ea06-acb0-7f12-81c6-34be49fea74c", + "pid": 1088711 + } + }, + "first_verified": { + "at": "2026-09-28T21:58:54.303Z", + "mono_ms": 88670, + "kind": "FIRST_VERIFIED", + "runtime": "rearm_pending", + "armed_to_verified_ms": 33102, + "after_failed_ms": null + }, + "configured_deadline_ms": 1790632821201, + "actual_deadline_from_armed_ms": 120000, + "probe": { + "delivery_id": "bff28485-9519-46e5-bf80-dddea1519b2f", + "digest": "4935272fe9a6dead93566f11d2c3a7a09a8a161d047552d73fcf4a1e43daa835", + "body": "配送確認の符号は c4f1ea51-659a-473a-908d-70ef7d4bd2d5。", + "complete": true, + "first_native_read": { + "observed_at": "2026-09-28T21:58:56.410Z", + "official_ts": 1790632736, + "line": 56 + }, + "first_model_literal": { + "observed_at": "2026-09-28T21:59:12.352Z", + "official_ts": 1790632752, + "line": 70 + } + }, + "last_room_health": { + "at": "2026-09-28T22:01:17.730Z", + "mono_ms": 232097, + "kind": "room_members_change", + "parent_receiver": { + "state": "up", + "endpoints": [ + { + "recipient": "probe-diag-a290277c", + "endpoint_id": "d5741286-070c-4621-925b-8758c55069c2", + "state": "armed", + "beat_at": "2026-09-28T22:01:17.506Z", + "detail": "{\"endpoint_id\":\"d5741286-070c-4621-925b-8758c55069c2\",\"state\":\"verified\",\"error_code\":null}" + } + ] + } + }, + "failed_observed": false, + "observation_window_ms": 180000, + "cleanup": { + "status": "passed", + "elapsed_ms": 500, + "method": "root fixture factory.close(stop→waitOwnedFixtureExit 30s→restore→assertHooksUnchanged)" + }, + "limitations": [ + "診断controllerの観測で正式受入を代用しない。製品sourceの次修理後は再測定が必要。", + "親の内部思考時間は観測していない。公式timestampは1秒精度。", + "Windows Cursorでは保存hook出力とmodel transcriptにprobe原文が無いため、verified状態だけでnative本文到達を断定しない。" + ], + "fingerprints": { + "summary.json": "1b05d4f090d0838a1b5bebbd7d8bbed12b4e286f2e5fe80a4b690648298718db", + "events.jsonl": "420b9236870591399f8830dd4438a51630a09d294d562d0ffc3c16300fafcb90", + "raw/updates.jsonl": "dc32aed8bee36445876eaefa9edc250d7c2a8e0e3c9b5233f646c3390719ee86", + "raw/product-hook-events.jsonl": "e14796d43f746d4040893ca0163df6818cdd9065a8fb829204b2dc79500aae00" + } +} diff --git a/rag/parent-delivery/windows-lock-contention-diagnosis.json b/rag/parent-delivery/windows-lock-contention-diagnosis.json index 2d9cd59c..ff68246a 100644 --- a/rag/parent-delivery/windows-lock-contention-diagnosis.json +++ b/rag/parent-delivery/windows-lock-contention-diagnosis.json @@ -60,7 +60,7 @@ "cim_share": 0.9912954484407862 }, "confirmed_cause": "ticketを毎回読む際のsameProcessが他PIDごとにCIMを同期起動し、重なったticketの本人確認だけで排他期限を超えている。", - "repair_status": "標準OS APIで同じPID-startを確認する案を所有dummyで検証中。排他algorithm・期限・永続identityを維持する。", + "repair_status": "標準OS APIで同じPID-startを確認する実装を採用。根拠はwindows-process-identity-diagnosis.json。排他algorithm・期限・永続identityを維持し、統合後の同じ6process再現は未測定。", "limitations": [ "旧84の失敗rawにはticket/CIM記録がないため、同一原因とは断定しない。", "6process同時投入は制御した再現条件で、実運用の発生頻度は未測定。", diff --git a/rag/parent-delivery/windows-process-identity-diagnosis.json b/rag/parent-delivery/windows-process-identity-diagnosis.json new file mode 100644 index 00000000..8e1ae7b0 --- /dev/null +++ b/rag/parent-delivery/windows-process-identity-diagnosis.json @@ -0,0 +1,91 @@ +{ + "schema": "peertable.windows-process-identity-diagnosis.v1", + "observed_at": "2026-09-28T22:36:42.066Z", + "source": "所有dummyと実Windows APIのraw記録。rootが原JSONと提案sourceを照合。", + "confidence": "実測。製品統合後の競合試験は別に必要。", + "formal_acceptance_credit": 0, + "platform": "win32", + "node": "v24.20.0", + "koffi": "3.3.2", + "elevated": true, + "precision": { + "cim_rows": 465, + "native_ok": 463, + "exact_100ns": 59, + "equal_after_truncate_us": 463, + "equal_after_round_us": 0, + "mismatch": [], + "open_failed": { + "OpenProcess:87": 2 + }, + "cim_last_digit_nonzero": 0, + "delta_ticks_hist": {} + }, + "live": { + "pid": 47612, + "cim_started": "2026-09-28T22:36:42.8649470Z", + "native": { + "alive": true, + "waitResult": 258, + "started_100ns": "2026-09-28T22:36:42.8649472Z", + "started_us": "2026-09-28T22:36:42.8649470Z" + }, + "equal_us": true, + "exact_100ns": false + }, + "exited_held": { + "pid": 67708, + "held_handle_ok": true, + "wait0": 0, + "cim_row": "ABSENT", + "get_process": "Get-Process:ABSENT" + }, + "exited_released": { + "ok": false, + "win32": 87, + "stage": "OpenProcess" + }, + "pid_reuse": { + "pid": 50260, + "first_started": "2026-09-28T22:36:45.0345963Z", + "second_started": "2026-09-28T22:36:46.3259653Z", + "spawned": 816, + "native_says_first_is_same_as_second": false + }, + "handles": { + "calls": 2000, + "handle_count_before": 201, + "handle_count_after": 202, + "closeHandle_returned_nonzero": true, + "GetHandleInformation_before_close": true, + "GetHandleInformation_after_close": false, + "win32_after_close": 6, + "closeHandleFailures": 0 + }, + "timing_ms": { + "native_mean_per_call": 0.0038, + "cim_via_pwsh_median": 386, + "cim_min": 380, + "cim_max": 524, + "ratio_cim_over_native": 102603 + }, + "implementation": "OpenProcessで取得した同一handleをGetProcessTimesとWaitForSingleObject(0)へ渡し、必ずCloseHandleする。FILETIMEをCIMの1µs切捨て・UTC7桁へ適合する。ERROR_INVALID_PARAMETERは終了、ACCESS_DENIEDとその他失敗はtyped error。", + "limitations": [ + "実測は昇格済みprocessで行った。非昇格での実測は未実施。", + "採用する権限の組合せでACCESS_DENIEDを実機再現していない。拒否とその他API失敗はfake APIによるfocused testで区別した。", + "タイミングはこの端末の平均と中央値の対照。競合頻度や製品の配達速度を表さない。", + "提案の14試験は実機とfakeの混在。製品統合後の6process競合とCursor本文到達は未測定。" + ], + "primary_sources": [ + "https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-openprocess", + "https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-getprocesstimes", + "https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-waitforsingleobject" + ], + "private_raw": "/private/tmp/claude-501/qa-cursor-grok/diag-980cbeb", + "fingerprints": { + "raw/winproc/result.json": "c2ebb61ad0c148cf729e53f84f89a07989dcdf0863fcbb3d43b987d27c835dad", + "raw/winproc/extra-elevated.json": "bed274fa4754af58ce6115cd1f9e7fe29855e1bcd5df515cf03c2ac8bb5d03b6", + "raw/winproc/proposal-test-result.json": "a86ec0f8043ffeabdf8f000735ba9c7862118b9278eb8883363223753ad9b664", + "controllers/winproc/parent-windows-process.proposal.mjs": "8ad77bc4853ba91d96d8ffdad7b96cd6589653946a414a354afe781c82465232" + } +} diff --git a/scripts/run-product-ci.mjs b/scripts/run-product-ci.mjs index 5bdccf70..d0ab458e 100644 --- a/scripts/run-product-ci.mjs +++ b/scripts/run-product-ci.mjs @@ -30,7 +30,7 @@ for (const directory of ['room', 'skill/scripts', 'scripts']) { run(process.execPath, ['--test', 'skill/scripts/runtime-contract.test.mjs']) run(process.execPath, ['experiments/aiterm-unified-delivery-repro.mjs']) run(process.execPath, ['--test', 'scripts/ci-contract.test.mjs', 'scripts/docs-contract.test.mjs']) -run(process.execPath, ['--test', 'skill/scripts/parent-delivery.test.mjs', 'skill/scripts/parent-background-input.test.mjs', 'skill/scripts/parent-runtime-probe.test.mjs', 'skill/scripts/parent-source-runtime.test.mjs', 'skill/scripts/parent-process.test.mjs', 'skill/scripts/parent-platform-atomic.test.mjs', 'skill/scripts/parent-platform-unlinked-executable.test.mjs', 'room/parent-client.test.mjs', 'scripts/parent-delivery-acceptance.test.mjs']) +run(process.execPath, ['--test', 'skill/scripts/parent-delivery.test.mjs', 'skill/scripts/parent-background-input.test.mjs', 'skill/scripts/parent-windows-process.test.mjs', 'skill/scripts/parent-runtime-probe.test.mjs', 'skill/scripts/parent-source-runtime.test.mjs', 'skill/scripts/parent-process.test.mjs', 'skill/scripts/parent-platform-atomic.test.mjs', 'skill/scripts/parent-platform-unlinked-executable.test.mjs', 'room/parent-client.test.mjs', 'scripts/parent-delivery-acceptance.test.mjs']) run(process.execPath, ['experiments/delivery-receipt-repro.mjs']) run(process.execPath, ['experiments/http-utf8-body-repro.mjs']) run(process.execPath, ['experiments/windows-seat-mux-repro.mjs']) diff --git a/skill/scripts/parent-connect.mjs b/skill/scripts/parent-connect.mjs index 141c16bd..302f5cbb 100644 --- a/skill/scripts/parent-connect.mjs +++ b/skill/scripts/parent-connect.mjs @@ -23,7 +23,7 @@ const pathFor = target => { export function hookEntries(target) { const command = hookCommand(process.execPath, [hookEntry, target]) const native = target === 'claude' ? { type: 'command', command: process.execPath, args: [hookEntry, target] } : { type: 'command', command } - if (target === 'cursor') return Object.fromEntries(['preToolUse', 'postToolUse', 'afterMCPExecution', 'sessionEnd'].map(name => [name, [{ command }]])) + if (target === 'cursor') return Object.fromEntries(['preToolUse', 'postToolUse', 'postToolUseFailure', 'afterMCPExecution', 'sessionEnd'].map(name => [name, [{ command }]])) if (target === 'claude') return { PreToolUse: [{ matcher: '^mcp__peertable_parent__parent_(join|read|leave)$', hooks: [{ ...native, timeout: 20 }] }], PostToolUse: [{ matcher: '^mcp__peertable_parent__parent_join$', hooks: [{ ...native, asyncRewake: true, timeout: 86400 }] }], diff --git a/skill/scripts/parent-delivery.test.mjs b/skill/scripts/parent-delivery.test.mjs index aa2eed98..6d15f7b4 100644 --- a/skill/scripts/parent-delivery.test.mjs +++ b/skill/scripts/parent-delivery.test.mjs @@ -375,13 +375,21 @@ test('ClaudeのPost/Stop同時開始は同じsessionで1slot、期限controlは assert.equal(spool.read().runtime, 'rearm_pending'); assert.equal(spool.read().records.length, 0) }) -test('Cursor afterMCPExecutionとpostToolUseは同じclaimを共有しbusy本文を1回だけ出す', async t => { +test('CursorはAitermと同じ成功・失敗後hookで本文を渡し、MCP束縛hookでは消費しない', async t => { assert.ok(hookEntries('cursor').afterMCPExecution) + assert.ok(hookEntries('cursor').postToolUseFailure) const spool = fixture(t); spool.saveEvent(event(1)); const output = [] const options = { checkTarget: async () => {} } const native = { hook_event_name: 'afterMCPExecution', conversation_id: '同じ会話', tool_use_id: 'official-use-id', mcp_server_name: 'peertable_parent', tool_name: 'parent_read', tool_input: '{"endpoint_id":"returned-endpoint"}', result_json: '{"content":[]}' } - const results = await Promise.all([cursorEvent(spool, native, async value => output.push(value), options), cursorEvent(spool, { ...native, hook_event_name: 'postToolUse', tool_name: 'MCP:parent_read', tool_input: { endpoint_id: 'returned-endpoint' } }, async value => output.push(value), options)]) + assert.equal(await cursorEvent(spool, native, async value => output.push(value), options), false) + assert.equal(output.length, 0) + assert.equal(spool.read().records[0].state, 'ready') + const results = await Promise.all(['postToolUse', 'postToolUseFailure'].map(hook_event_name => cursorEvent(spool, { ...native, hook_event_name, tool_name: 'MCP:parent_read', tool_input: { endpoint_id: 'returned-endpoint' } }, async value => output.push(value), options))) assert.equal(results.filter(Boolean).length, 1); assert.equal(output.length, 1) assert.match(output[0].additional_context, /日本語\n"引用"😀/u) assert.equal(spool.read().records[0].receipt.result, 'delivered') + spool.saveEvent(event(2, '作業toolが失敗してもこの本文を受け取る')) + assert.equal(await cursorEvent(spool, { hook_event_name: 'postToolUseFailure', tool_name: 'Shell', tool_input: { command: '失敗した作業' } }, async value => output.push(value), options), true) + assert.match(output[1].additional_context, /作業toolが失敗してもこの本文を受け取る/u) + assert.equal(spool.read().records[1].receipt.result, 'delivered') }) diff --git a/skill/scripts/parent-hook.mjs b/skill/scripts/parent-hook.mjs index 62938cf9..d5451ba2 100644 --- a/skill/scripts/parent-hook.mjs +++ b/skill/scripts/parent-hook.mjs @@ -42,7 +42,7 @@ try { if (['SessionEnd', 'sessionEnd'].includes(eventName)) { await stopEndpoint(spool); continue } if (harness === 'codex' && ['PostToolUse', 'Stop'].includes(eventName)) { if (await codexHook(spool, event, writeJson)) { emitted = true; break } - } else if (['cursor', 'grok'].includes(harness) && ['postToolUse', 'PostToolUse', 'afterMCPExecution'].includes(eventName)) { + } else if (['cursor', 'grok'].includes(harness) && ['postToolUse', 'postToolUseFailure', 'PostToolUse', 'afterMCPExecution'].includes(eventName)) { try { if (harness === 'grok') await bindNativeWait(spool, event, harness) } catch (error) { spool.update({ runtime: 'failed', error_code: error.code, error_detail: error.message }); throw error } if (harness === 'cursor' && await cursorEvent(spool, event, writeJson)) { emitted = true; break } diff --git a/skill/scripts/parent-platform.mjs b/skill/scripts/parent-platform.mjs index 9d0085a5..80342360 100644 --- a/skill/scripts/parent-platform.mjs +++ b/skill/scripts/parent-platform.mjs @@ -6,6 +6,7 @@ import { createRequire } from 'node:module' import { homedir } from 'node:os' import { randomUUID } from 'node:crypto' import { replaceWindowsFile } from './parent-windows-files.mjs' +import { sameWindowsProcess } from './parent-windows-process.mjs' export const parentHome = () => join(homedir(), '.peertable', 'parent-receivers') export const failure = (code, detail = code) => Object.assign(new Error(detail), { code }) @@ -92,7 +93,7 @@ export function processIdentity(pid, { includeExecutable = true } = {}) { throw failure('PARENT_PROCESS_API_FAILED', `PID ${pid}: ${error.message}`) } } -export const sameProcess = owner => owner && processIdentity(owner.pid, { includeExecutable: false })?.started === owner.started +export const sameProcess = owner => owner && (process.platform === 'win32' ? sameWindowsProcess(owner) : processIdentity(owner.pid, { includeExecutable: false })?.started === owner.started) export function processDescendsFrom(owner, ancestor) { if (!sameProcess(owner) || !sameProcess(ancestor)) return false for (let pid = owner.pid, depth = 0; pid > 0 && depth < 32; depth++) { diff --git a/skill/scripts/parent-receivers/cursor.mjs b/skill/scripts/parent-receivers/cursor.mjs index a4a4357e..b740b97d 100644 --- a/skill/scripts/parent-receivers/cursor.mjs +++ b/skill/scripts/parent-receivers/cursor.mjs @@ -1,8 +1,9 @@ import { renderDelivery, PAGE_CHARS } from '../parent-delivery.mjs' export { waitReceipt, bindNativeWait, backgroundReceive } from './background.mjs' export async function cursorEvent(spool, event, write, options = {}) { - if (!['afterMCPExecution', 'postToolUse'].includes(event.hook_event_name)) return false - // afterMCPExecutionのtool_input/result_jsonは実測上JSON文字列。native登録はpostToolUseが所有する。 + // Aitermと同じくafterMCPExecutionは会話束縛だけ。本文は成功・失敗後のtool hookへ渡す。 + if (!['postToolUse', 'postToolUseFailure'].includes(event.hook_event_name)) return false + // 失敗したShellを背景taskの登録成功として扱わない。 if (event.hook_event_name === 'postToolUse') await (await import('./background.mjs')).bindNativeWait(spool, event, 'cursor') return cursorHook(spool, write, options) } diff --git a/skill/scripts/parent-windows-process.mjs b/skill/scripts/parent-windows-process.mjs new file mode 100644 index 00000000..f44a5c25 --- /dev/null +++ b/skill/scripts/parent-windows-process.mjs @@ -0,0 +1,61 @@ +// Windows標準APIで本人存命だけを確認する。parent/exe/commandの取得はCIMが所有する。 +// 1回のOpenProcessで得たhandleから「開始時刻」と「存命」を同時に読むので、PID再利用の照会間隙が無い。 +import { createRequire } from 'node:module' + +const QUERY_LIMITED_INFORMATION = 0x1000, SYNCHRONIZE = 0x100000 +const WAIT_OBJECT_0 = 0, WAIT_TIMEOUT = 0x102, WAIT_FAILED = 0xffffffff +const ERROR_ACCESS_DENIED = 5, ERROR_INVALID_PARAMETER = 87 +const EPOCH_DIFF_100NS = 116444736000000000n // 1601-01-01 → 1970-01-01 + +let api +function windowsApi() { + if (api) return api + const koffi = createRequire(import.meta.url)('koffi') + const kernel = koffi.load('kernel32.dll') + api = { + open: kernel.func('intptr_t __stdcall OpenProcess(uint32_t access, int32_t inherit, uint32_t pid)'), + times: kernel.func('int32_t __stdcall GetProcessTimes(intptr_t process, void *creation, void *exit, void *kernel, void *user)'), + wait: kernel.func('uint32_t __stdcall WaitForSingleObject(intptr_t handle, uint32_t milliseconds)'), + close: kernel.func('int32_t __stdcall CloseHandle(intptr_t handle)'), + error: kernel.func('uint32_t __stdcall GetLastError()'), + } + return api +} +const failure = (code, detail, extra = {}) => Object.assign(new Error(detail), { code, ...extra }) + +// FILETIME(100ns, 1601起点) → 製品が永続する形式(.NET 'o'、UTC、7桁)。CIM CreationDateの粒度(1µs、切捨て)に揃える。 +export function startedFromFileTime(filetime) { + const truncated = filetime - (filetime % 10n) + const seconds = truncated / 10000000n - EPOCH_DIFF_100NS / 10000000n + const fraction = String(truncated % 10000000n).padStart(7, '0') + return `${new Date(Number(seconds * 1000n)).toISOString().slice(0, 19)}.${fraction}Z` +} + +// 戻り値: { alive: false } | { alive: true, started }。判定できない失敗は退避せずtyped errorにする。 +export function windowsProcessState(pid, native = windowsApi()) { + const handle = native.open(QUERY_LIMITED_INFORMATION | SYNCHRONIZE, 0, Number(pid)) + if (!handle) { + const error = native.error() + if (error === ERROR_INVALID_PARAMETER) return { alive: false } // 該当PIDのprocess objectが無い + throw failure(error === ERROR_ACCESS_DENIED ? 'PARENT_PROCESS_ACCESS_DENIED' : 'PARENT_PROCESS_API_FAILED', `OpenProcess PID ${pid}: Windows error ${error}`, { win32_error: error }) + } + let result, operationError, closeError + try { + const creation = Buffer.alloc(8) + if (!native.times(handle, creation, Buffer.alloc(8), Buffer.alloc(8), Buffer.alloc(8))) { const error = native.error(); throw failure('PARENT_PROCESS_API_FAILED', `GetProcessTimes PID ${pid}: Windows error ${error}`, { win32_error: error }) } + const waited = native.wait(handle, 0) + if (waited === WAIT_FAILED) { const error = native.error(); throw failure('PARENT_PROCESS_API_FAILED', `WaitForSingleObject PID ${pid}: Windows error ${error}`, { win32_error: error }) } + result = waited === WAIT_TIMEOUT ? { alive: true, started: startedFromFileTime(creation.readBigUInt64LE(0)) } : { alive: false } + if (waited !== WAIT_TIMEOUT && waited !== WAIT_OBJECT_0) throw failure('PARENT_PROCESS_API_FAILED', `WaitForSingleObject PID ${pid}: unexpected ${waited}`) + } catch (error) { operationError = error } finally { + if (!native.close(handle)) closeError = native.error() + } + if (closeError !== undefined) throw failure('PARENT_PROCESS_API_FAILED', `CloseHandle PID ${pid}: Windows error ${closeError}`, { win32_error: closeError, cause: operationError }) + if (operationError) throw operationError + return result +} +export const sameWindowsProcess = (owner, native) => { + if (!Number.isSafeInteger(Number(owner.pid)) || Number(owner.pid) <= 0) throw failure('PARENT_PROCESS_ID_INVALID', 'PIDが正の整数ではありません') + const state = windowsProcessState(owner.pid, native) + return state.alive && state.started === owner.started +} diff --git a/skill/scripts/parent-windows-process.test.mjs b/skill/scripts/parent-windows-process.test.mjs new file mode 100644 index 00000000..828660d5 --- /dev/null +++ b/skill/scripts/parent-windows-process.test.mjs @@ -0,0 +1,51 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { startedFromFileTime, windowsProcessState, sameWindowsProcess } from './parent-windows-process.mjs' +import { processIdentity, sameProcess } from './parent-platform.mjs' + +const creation = 134039731428649472n +function native(overrides = {}) { + const calls = [] + return { calls, open: (...args) => { calls.push(['open', ...args]); return 42 }, + times: (handle, output) => { calls.push(['times', handle]); output.writeBigUInt64LE(creation); return 1 }, + wait: (handle, milliseconds) => { calls.push(['wait', handle, milliseconds]); return 258 }, + close: handle => { calls.push(['close', handle]); return 1 }, error: () => 5, ...overrides } +} + +test('FILETIMEを永続CIMの1µs・7桁UTCへ変換し、100nsとmsへ丸めない', () => { + assert.equal(startedFromFileTime(creation), '2025-10-03T13:52:22.8649470Z') + assert.equal(startedFromFileTime(116444735999999999n), '1969-12-31T23:59:59.9999990Z') + assert.equal(startedFromFileTime(116444736000000000n), '1970-01-01T00:00:00.0000000Z') +}) + +test('同じhandleの開始時刻と存命を照合し、別startedと終了をfalseにする', () => { + const api = native(), owner = { pid: 123, started: startedFromFileTime(creation) } + assert.equal(sameWindowsProcess(owner, api), true) + assert.deepEqual(api.calls, [['open', 0x101000, 0, 123], ['times', 42], ['wait', 42, 0], ['close', 42]]) + assert.equal(sameWindowsProcess({ ...owner, started: '2025-10-03T13:52:22.8649460Z' }, native()), false) + assert.deepEqual(windowsProcessState(123, native({ wait: () => 0 })), { alive: false }) + assert.deepEqual(windowsProcessState(123, native({ open: () => 0, error: () => 87 })), { alive: false }) +}) + +test('拒否とAPI失敗を終了にせず、開いたhandleは失敗時も閉じる', () => { + assert.throws(() => windowsProcessState(123, native({ open: () => 0 })), { code: 'PARENT_PROCESS_ACCESS_DENIED', win32_error: 5 }) + for (const overrides of [{ times: () => 0 }, { wait: () => 0xffffffff }, { wait: () => 17 }, { close: () => 0 }]) { + const api = native(overrides) + assert.throws(() => windowsProcessState(123, api), { code: 'PARENT_PROCESS_API_FAILED' }) + if (overrides.close === undefined) assert.deepEqual(api.calls.at(-1), ['close', 42]) + } + assert.throws(() => sameWindowsProcess({ pid: -1 }, native()), { code: 'PARENT_PROCESS_ID_INVALID' }) +}) + +test('Windows実childのCIM本人を同じnative handleで確認し、終了後は一致しない', { skip: process.platform !== 'win32' }, async t => { + const child = spawn(process.execPath, ['-e', 'process.stdin.resume()'], { stdio: ['pipe', 'ignore', 'ignore'] }) + const exited = once(child, 'exit') + t.after(async () => { if (child.exitCode === null && child.signalCode === null) child.kill(); await exited }) + const owner = processIdentity(child.pid) + assert.equal(sameProcess(owner), true) + assert.equal(sameProcess({ ...owner, started: owner.started.replace(/\dZ$/u, '1Z') }), false) + child.stdin.end(); await exited + assert.equal(sameProcess(owner), false) +}) From f48d0c9106d89d1aa880848fea282e81acbf25b7 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:14:17 +0900 Subject: [PATCH 72/72] =?UTF-8?q?=E4=BB=8A=E5=9B=9E=E3=81=AE=E5=85=AC?= =?UTF-8?q?=E9=96=8B=E8=A3=81=E5=AE=9A=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=97?= =?UTF-8?q?=E5=AE=9F=E6=A9=9F=E6=9C=AA=E7=A2=BA=E8=AA=8D=E3=82=92=E4=BF=9D?= =?UTF-8?q?=E6=8C=81=E3=81=97=E3=81=A6=E3=83=AA=E3=83=AA=E3=83=BC=E3=82=B9?= =?UTF-8?q?=E3=82=92=E8=A8=B1=E5=8F=AF=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.ja.md | 2 +- README.md | 2 +- deploy/README.md | 2 +- docs/current-design.md | 2 + docs/plan_parent-native-delivery.md | 4 + rag/INDEX.md | 1 + .../release-candidate-checkpoint.json | 51 ++--- rag/parent-delivery/release-decision.json | 14 ++ .../980cbeb/release-candidate-checkpoint.json | 180 ++++++++++++++++++ scripts/parent-delivery-acceptance.mjs | 16 +- scripts/parent-delivery-acceptance.test.mjs | 10 +- scripts/prepublish.mjs | 2 +- 12 files changed, 255 insertions(+), 31 deletions(-) create mode 100644 rag/parent-delivery/release-decision.json create mode 100644 rag/parent-delivery/snapshots/980cbeb/release-candidate-checkpoint.json diff --git a/README.ja.md b/README.ja.md index 08cb3f3c..ca067961 100644 --- a/README.ja.md +++ b/README.ja.md @@ -99,7 +99,7 @@ setupの再実行も既存projectではresumeへ進み、room・議題・生存 teardownの既定は解散。席と所有する足場を撤去し、roomと過去ログ、Lattice storeを残す。`--purge`はroomと新設storeも削除する。既存設定・無関係な作業差分は保つ。停止またはroom操作に失敗した場合は再実行用の記録を残してエラーを返す。 -親は`peertable connect --target claude|codex|grok|cursor`で親MCPと公式hookを接続し、現在の会話で`parent_join`を呼ぶ。親宛DM・複数人宛・all全件を原文のまま受信する。Cursor/Grokは返却された完成済みnative背景tool入力を登録する。接続設定だけで着卓完了とせず、耳疎通と現在の受信継続を確認する。親配送の現行契約と受入の入口は[現行設計](https://github.com/kitepon/peertable/blob/main/docs/current-design.md)を参照する。親の耳疎通、kickoffの引受確認、席設定変更の手順は[同梱スキル](skill/SKILL.md)にまとめている。 +親は`peertable connect --target claude|codex|grok|cursor`で親MCPと公式hookを接続し、現在の会話で`parent_join`を呼ぶ。親宛DM・複数人宛・all全件を原文のまま受信する。Cursor/Grokは返却された完成済みnative背景tool入力を登録する。接続設定だけで着卓完了とせず、耳疎通と現在の受信継続を確認する。親配送の現行契約と受入の入口は[現行設計](https://github.com/kitepon/peertable/blob/main/docs/current-design.md)を参照する。親の耳疎通、kickoffの引受確認、席設定変更の手順は[同梱スキル](skill/SKILL.md)にまとめている。 親配送の全利用面の実機検証は未完了である。公開時の検証範囲と未確認項目は[公開裁定](https://github.com/kitepon/peertable/blob/main/rag/parent-delivery/release-decision.json)を参照する。 **roomがメンバーの唯一の台帳。** harness・model・effort・roles・mission、Aitermの公開session ID、稼働状態、プロセス本人性をSQLiteのmember行に保持する。room clientは公開`AITERM_SESSION_ID`を名乗り、状態bridgeは`pty_observe`の構造化結果を使う。PeertableはAitermの内部ファイル、socket、namespace、画面文言を解析しない。 diff --git a/README.md b/README.md index 5699ba9c..d43fb975 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ setupの再実行も既存projectではresumeへ進み、room・議題・生存 teardownの既定は解散。席と所有する足場を撤去し、roomと過去ログ、Lattice storeを残す。`--purge`はroomと新設storeも削除する。既存設定・無関係な作業差分は保つ。停止またはroom操作に失敗した場合は再実行用の記録を残してエラーを返す。 -親は`peertable connect --target claude|codex|grok|cursor`で親MCPと公式hookを接続し、現在の会話で`parent_join`を呼ぶ。親宛DM・複数人宛・all全件を原文のまま受信する。Cursor/Grokは返却された完成済みnative背景tool入力を登録する。接続設定だけで着卓完了とせず、耳疎通と現在の受信継続を確認する。親配送の現行契約と受入の入口は[現行設計](https://github.com/kitepon/peertable/blob/main/docs/current-design.md)を参照する。親の耳疎通、kickoffの引受確認、席設定変更の手順は[同梱スキル](skill/SKILL.md)にまとめている。 +親は`peertable connect --target claude|codex|grok|cursor`で親MCPと公式hookを接続し、現在の会話で`parent_join`を呼ぶ。親宛DM・複数人宛・all全件を原文のまま受信する。Cursor/Grokは返却された完成済みnative背景tool入力を登録する。接続設定だけで着卓完了とせず、耳疎通と現在の受信継続を確認する。親配送の現行契約と受入の入口は[現行設計](https://github.com/kitepon/peertable/blob/main/docs/current-design.md)を参照する。親の耳疎通、kickoffの引受確認、席設定変更の手順は[同梱スキル](skill/SKILL.md)にまとめている。 親配送の全利用面の実機検証は未完了である。公開時の検証範囲と未確認項目は[公開裁定](https://github.com/kitepon/peertable/blob/main/rag/parent-delivery/release-decision.json)を参照する。 **roomがメンバーの唯一の台帳。** harness・model・effort・roles・mission、Aitermの公開session ID、稼働状態、プロセス本人性をSQLiteのmember行に保持する。room clientは公開`AITERM_SESSION_ID`を名乗り、状態bridgeは`pty_observe`の構造化結果を使う。PeertableはAitermの内部ファイル、socket、namespace、画面文言を解析しない。 diff --git a/deploy/README.md b/deploy/README.md index 27823abe..5bfda278 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -74,7 +74,7 @@ curl -sN --max-time 30 https://peertable.kitepon.dev/api//events | head -6 ## 親配送の変更を反映する時 -親配送を含むreleaseでは、`npm run verify:parent-delivery`の合格と既定ブランチへの着地を先に確認する。roomの親`parent_receiver`、宛先別`unknown` receiptとhealth、UTF-8本文保存はサーバー側の契約であり、clientの導入だけで本番反映済みとしない。roomの入替と公開API確認を済ませてから、利用端末へregistry版をglobal installし、同じ親会話へのDM・複数人宛・allの公開後smokeを行う。旧imageへ戻す時もログvolumeは保持する。 +親配送を含むreleaseでは、`npm run verify:parent-delivery`の合格と既定ブランチへの着地を先に確認する。オーナーが実機受入の完了前に公開を指示した対象だけは、[公開裁定](../rag/parent-delivery/release-decision.json)のversion・runtime一致を確認して進め、未確認を合格に変更しない。roomの親`parent_receiver`、宛先別`unknown` receiptとhealth、UTF-8本文保存はサーバー側の契約であり、clientの導入だけで本番反映済みとしない。roomの入替と公開API確認を済ませてから、利用端末へregistry版をglobal installし、同じ親会話へのDM・複数人宛・allの公開後smokeを行う。旧imageへ戻す時もログvolumeは保持する。 ## ロールバック diff --git a/docs/current-design.md b/docs/current-design.md index 5918619d..e9f287b1 100644 --- a/docs/current-design.md +++ b/docs/current-design.md @@ -67,6 +67,8 @@ Cursorの本文差し込みはAitermの受信handlerと同じ`postToolUse`/`po 耳疎通の`verified`と現在の受信継続は別に確認する。runtimeは`armed`、`rearm_pending`、`stopped`、`failed`を区別し、Cursor/Grokはreceiptの完成済みnative tool入力を登録して次の受信を維持する。故障は宛先別receipt、health、診断へ原因code付きで出す。旧parent_watchは移行診断の対象である。 +今回の親配送releaseは、3 OSの製品CI合格後、オーナーの明示指示で全正式実機受入の完了を待たず公開する。[公開裁定](../rag/parent-delivery/release-decision.json)が対象version・runtimeと未確認項目の正本であり、対応設計と実機検証済みの範囲を区別する。 + 受付不明は`unknown`として原文と受付証拠を保持し、自動再送しない。Codexのqueue受付ID・時刻は後続receiptでも保持する。長文は同じ配送ID・digestへ束縛した継続tokenで最後まで読み、最後の出力完了までackしない。 このcampaignの製品実機受入・公開は未完了。[親配送計画](plan_parent-native-delivery.md)と[実機受入目録](../rag/parent-delivery/product-acceptance.json)の全必須面が成立するまで公開判定を行わない。 diff --git a/docs/plan_parent-native-delivery.md b/docs/plan_parent-native-delivery.md index 2b9f1743..b50df0ab 100644 --- a/docs/plan_parent-native-delivery.md +++ b/docs/plan_parent-native-delivery.md @@ -417,3 +417,7 @@ Codexの`binding_deadline`前半は、[公式callerの比較](../rag/parent-deli [AitermのCursor受信実装との対照](../rag/parent-delivery/cursor-aiterm-parity.md)に従い、本文を`postToolUse`/`postToolUseFailure`だけへ出し、`afterMCPExecution`は束縛だけに揃えた。Aitermのhandlerは失敗後hookにも対応するが、参照時点のsetupは成功後hookだけを登録している。Peertableは本計画の失敗後hook契約も登録する。Aiterm本体は変更しない。 [Windows本人確認APIの実測](../rag/parent-delivery/windows-process-identity-diagnosis.json)では、既存CIM形式とnative APIが463件すべて一致し、終了processと実PID再利用も区別できた。同じhandleで開始時刻と存命を読む実装を採用し、親process・実行file・commandの取得は従来のCIMに残す。排他の仕組み・期限・永続identityは変えない。修理後配布物の6process競合と実Cursor受信は次の確認とする。 + +## 今回の公開裁定 + +オーナーの「いいよもう。一旦それでリリース、インストールして。」を受け、[対象を固定した裁定](../rag/parent-delivery/release-decision.json)のversion・runtimeは全正式実機受入の完了を待たず公開する。3 OS製品CIの合格は確認済み。未実施・失敗・長時間leaseを合格に変更せず、現行manifestを保持する。この裁定を次versionへ自動継承しない。main着地、Trusted Publishing、registry導入、本番反映と公開後smokeを今回の完了範囲とする。 diff --git a/rag/INDEX.md b/rag/INDEX.md index 1203f043..a2a33b95 100644 --- a/rag/INDEX.md +++ b/rag/INDEX.md @@ -23,3 +23,4 @@ - [Windows排他の実再現](parent-delivery/windows-lock-contention-diagnosis.json) — 6process同時更新でCIM本人確認が10秒期限を占めた原記録。 - [Windows本人確認APIの実測](parent-delivery/windows-process-identity-diagnosis.json) — native handleの存命・開始時刻とCIM互換性、実PID再利用、計測の限界。 - [120秒期限のLinux Grok診断](parent-delivery/linux-grok-probe-repair-diagnosis.json) — 正規本文回収33.102秒とhealth復旧。次のsourceの正式受入には算入しない。 +- [親配送の公開裁定](parent-delivery/release-decision.json) — オーナーが正式実機受入の完了を待たず公開するよう指示した対象version・runtimeと未確認範囲。 diff --git a/rag/parent-delivery/release-candidate-checkpoint.json b/rag/parent-delivery/release-candidate-checkpoint.json index 44d37438..31e57d43 100644 --- a/rag/parent-delivery/release-candidate-checkpoint.json +++ b/rag/parent-delivery/release-candidate-checkpoint.json @@ -1,22 +1,21 @@ { "schema": "peertable.parent-release-checkpoint.v1", - "source_commit": "980cbebc1acab7461daa064abec8814b410f0137", - "runtime_digest": "19cceff45965f64a0733b9bc8f207fca58a2295b8d0c54ad1e0deb72f573c5b1", + "source_commit": "6689b32164f26d32ec2212159074e1cd9369e42d", + "runtime_digest": "9b4e7b94db839946b669ea61f714ff60431b9b3d0de3f9e5f920fc281b2c263c", "package_version": "0.8.63", - "candidate_state": "diagnostic_candidate_native_input_and_windows_identity_repair_pending", - "source_frozen": false, - "previous_candidate_checkpoint": "rag/parent-delivery/snapshots/c97b431/release-candidate-checkpoint.json", + "candidate_state": "owner_approved_release_live_acceptance_deferred", + "source_frozen": true, + "previous_candidate_checkpoint": "rag/parent-delivery/snapshots/980cbeb/release-candidate-checkpoint.json", "product_ci": { "status": "success", - "source_commit": "980cbebc1acab7461daa064abec8814b410f0137", - "head_commit": "980cbebc1acab7461daa064abec8814b410f0137", - "url": "https://github.com/kitepon/peertable/actions/runs/36488941014", + "source_commit": "6689b32164f26d32ec2212159074e1cd9369e42d", + "head_commit": "6689b32164f26d32ec2212159074e1cd9369e42d", + "url": "https://github.com/kitepon/peertable/actions/runs/36496202551", "native_os": { "darwin": "passed", "linux": "passed", "win32": "passed" - }, - "previous_failure_evidence": "rag/parent-delivery/windows-rename-ci-diagnosis.json" + } }, "focused_verification": { "darwin_process_exit": { @@ -96,10 +95,10 @@ }, "package": { "status": "fixture_installed_diagnostics_only", - "sha256": "9fc56bbce1f49015e49de9ca4110303ca83f7acd8b3e6b66e4b3d9f57b71bdad", - "files": 135, - "dry_run_files": 135, - "installed_runtime_digest": "19cceff45965f64a0733b9bc8f207fca58a2295b8d0c54ad1e0deb72f573c5b1", + "sha256": "faac3c786f2f7807e2b56ee39759a6bb66baea3759de4c6e822fee75ca87aa3a", + "files": 138, + "dry_run_files": 138, + "installed_runtime_digest": "9b4e7b94db839946b669ea61f714ff60431b9b3d0de3f9e5f920fc281b2c263c", "registry_install": false, "skill_placement": { "status": "required", @@ -120,7 +119,8 @@ "errors": { "PARENT_ACCEPTANCE_MISSING": 525 }, - "checkpoint_counts_as_live_acceptance": false + "checkpoint_counts_as_live_acceptance": false, + "release_requirement": "deferred_by_owner_for_this_version_and_runtime_only" }, "lease": { "status": "withdrawn_monitors_finished_cleanup_review_pending", @@ -129,7 +129,7 @@ "source_commit": "84de74a097469ef4abd43698635805696b1302ff", "reason": "製品runtimeの再登録欠陥で候補撤回。原記録を保持し、所有試験だけを停止する。", "active": 0, - "cleanup_status": "rootは旧Mac/Linux Claude既知processの存命0を独立確認。旧controller finallyは未実行でsummary running残留。新run前に中断入口の修理が必要。", + "cleanup_status": "中断入口は43cba5fで修理済み。旧runは正常終了やlease合格に数えない。追加実測は今回の公開条件から延期。", "cleanup_evidence": "rag/parent-delivery/withdrawn-lease-stop-audit.json" }, "gui_observation": { @@ -143,14 +143,15 @@ "registry_install": false, "previous_product_ci": { "status": "success", - "source_commit": "356f9c408bfb8a46327651130b513b21b1b2842a", - "head_commit": "590f5a8b65c0a00b7b0877495da2c6a65428101c", - "url": "https://github.com/kitepon/peertable/actions/runs/36478777216", + "source_commit": "980cbebc1acab7461daa064abec8814b410f0137", + "head_commit": "980cbebc1acab7461daa064abec8814b410f0137", + "url": "https://github.com/kitepon/peertable/actions/runs/36488941014", "native_os": { "darwin": "passed", "linux": "passed", "win32": "passed" - } + }, + "previous_failure_evidence": "rag/parent-delivery/windows-rename-ci-diagnosis.json" }, "repair_commit": "bce891b2cbf3a77afa7ab738e19f965e7ac9808c", "withdrawal_evidence": "rag/parent-delivery/codex-probe-rejoin-diagnosis.json", @@ -169,12 +170,14 @@ "evidence": "rag/parent-delivery/codex-probe-rejoin-repair-diagnosis.json" }, "cursor_native_input": { - "state": "focused_passed_controller_and_native_verification_pending", + "state": "focused_and_three_os_ci_passed_live_diagnosis_in_progress", "evidence": "rag/parent-delivery/cursor-native-input-diagnosis.json" }, "windows_lock_contention": { - "state": "real_timeout_reproduced_native_identity_adapter_diagnosis_pending", - "evidence": "rag/parent-delivery/windows-lock-contention-diagnosis.json" + "state": "native_identity_integrated_three_os_ci_passed", + "evidence": "rag/parent-delivery/windows-lock-contention-diagnosis.json", + "identity_evidence": "rag/parent-delivery/windows-process-identity-diagnosis.json" } - } + }, + "release_decision": "rag/parent-delivery/release-decision.json" } diff --git a/rag/parent-delivery/release-decision.json b/rag/parent-delivery/release-decision.json new file mode 100644 index 00000000..5f867f3a --- /dev/null +++ b/rag/parent-delivery/release-decision.json @@ -0,0 +1,14 @@ +{ + "schema": "peertable.release-decision.v1", + "decision": "release_with_live_acceptance_deferred", + "package_version": "0.8.63", + "source_commit": "6689b32164f26d32ec2212159074e1cd9369e42d", + "runtime_digest": "9b4e7b94db839946b669ea61f714ff60431b9b3d0de3f9e5f920fc281b2c263c", + "owner_instruction": "いいよもう。一旦それでリリース、インストールして。", + "recorded_date": "2026-09-29", + "product_ci": "https://github.com/kitepon/peertable/actions/runs/36496202551", + "verified": ["macOS・Linux・Windows nativeの製品CI合格", "npm packの配布fileとruntime一致"], + "deferred": ["3 OS・4ハーネスの全必須利用面における正式実機受入の完了", "長時間leaseの実機受入", "Cursor Desktop・Codex Desktop/IDEの未実測項目"], + "formal_acceptance_credit": 0, + "scope": "このversionとruntimeに限り正式実機受入の完了を公開条件から外す。未実施・失敗をpassedにせず、次versionや変更されたruntimeへ裁定を継承しない。" +} diff --git a/rag/parent-delivery/snapshots/980cbeb/release-candidate-checkpoint.json b/rag/parent-delivery/snapshots/980cbeb/release-candidate-checkpoint.json new file mode 100644 index 00000000..44d37438 --- /dev/null +++ b/rag/parent-delivery/snapshots/980cbeb/release-candidate-checkpoint.json @@ -0,0 +1,180 @@ +{ + "schema": "peertable.parent-release-checkpoint.v1", + "source_commit": "980cbebc1acab7461daa064abec8814b410f0137", + "runtime_digest": "19cceff45965f64a0733b9bc8f207fca58a2295b8d0c54ad1e0deb72f573c5b1", + "package_version": "0.8.63", + "candidate_state": "diagnostic_candidate_native_input_and_windows_identity_repair_pending", + "source_frozen": false, + "previous_candidate_checkpoint": "rag/parent-delivery/snapshots/c97b431/release-candidate-checkpoint.json", + "product_ci": { + "status": "success", + "source_commit": "980cbebc1acab7461daa064abec8814b410f0137", + "head_commit": "980cbebc1acab7461daa064abec8814b410f0137", + "url": "https://github.com/kitepon/peertable/actions/runs/36488941014", + "native_os": { + "darwin": "passed", + "linux": "passed", + "win32": "passed" + }, + "previous_failure_evidence": "rag/parent-delivery/windows-rename-ci-diagnosis.json" + }, + "focused_verification": { + "darwin_process_exit": { + "passed": 2, + "failed": 0 + }, + "parent_delivery_and_harness_identity": { + "passed": 29, + "failed": 0 + }, + "native_fixture_and_lease_entry": { + "passed": 24, + "failed": 0 + }, + "scenario_transcript_observation": { + "passed": 22, + "failed": 0 + }, + "initial_codex_probe_deadline": { + "passed": 1, + "failed": 0 + }, + "source_runtime_and_independent_probe_timer": { + "passed": 3, + "failed": 0 + }, + "native_hook_fault_and_surface_registration": { + "passed": 36, + "failed": 0 + }, + "codex_real_os_probe_deadline": { + "product_live": false, + "health_delay_ms": 10, + "formal_acceptance_credit": 0, + "evidence": "rag/parent-delivery/codex-probe-timer-repair-diagnosis.json" + }, + "rejoin_probe_record_preservation": { + "passed": 1, + "failed": 0 + }, + "native_probe_fault_controller": { + "passed": 32, + "failed": 0, + "live_acceptance_credit": 0 + }, + "codex_spawn_lease_controller": { + "passed": 23, + "failed": 0, + "live_acceptance_credit": 0 + }, + "windows_atomic_replace": { + "passed": 31, + "failed": 0, + "formal_acceptance_credit": 0, + "diagnostic_pack_only": true + }, + "native_deadline_and_rejoin_controller": { + "passed": 25, + "failed": 0, + "live_acceptance_credit": 0 + }, + "acceptance_step_gate": { + "passed": 16, + "failed": 0, + "live_acceptance_credit": 0 + }, + "acceptance_controller_contract_provenance": { + "passed": 1, + "failed": 0, + "live_acceptance_credit": 0 + } + }, + "documentation_tests": { + "passed": 6, + "failed": 0, + "skipped": 0 + }, + "package": { + "status": "fixture_installed_diagnostics_only", + "sha256": "9fc56bbce1f49015e49de9ca4110303ca83f7acd8b3e6b66e4b3d9f57b71bdad", + "files": 135, + "dry_run_files": 135, + "installed_runtime_digest": "19cceff45965f64a0733b9bc8f207fca58a2295b8d0c54ad1e0deb72f573c5b1", + "registry_install": false, + "skill_placement": { + "status": "required", + "reason": "専用prefixのfixture導入はignore-scriptsを使い、既存のglobalスキル配置を変更していない。registryのglobal installは公開後に行う。" + }, + "parent_connections": { + "codex": "previous_candidate", + "claude": "previous_candidate", + "cursor": "previous_candidate", + "grok": "previous_candidate" + } + }, + "product_acceptance": { + "manifest": "rag/parent-delivery/product-acceptance.json", + "status": "failed", + "expected": 525, + "records": 0, + "errors": { + "PARENT_ACCEPTANCE_MISSING": 525 + }, + "checkpoint_counts_as_live_acceptance": false + }, + "lease": { + "status": "withdrawn_monitors_finished_cleanup_review_pending", + "reported_live_before_withdrawal": 2, + "passed": 0, + "source_commit": "84de74a097469ef4abd43698635805696b1302ff", + "reason": "製品runtimeの再登録欠陥で候補撤回。原記録を保持し、所有試験だけを停止する。", + "active": 0, + "cleanup_status": "rootは旧Mac/Linux Claude既知processの存命0を独立確認。旧controller finallyは未実行でsummary running残留。新run前に中断入口の修理が必要。", + "cleanup_evidence": "rag/parent-delivery/withdrawn-lease-stop-audit.json" + }, + "gui_observation": { + "status": "blocked", + "tool": "jev-desktop", + "alternative_permission_pending": true, + "test_windows_closed": true + }, + "published": false, + "production_deployed": false, + "registry_install": false, + "previous_product_ci": { + "status": "success", + "source_commit": "356f9c408bfb8a46327651130b513b21b1b2842a", + "head_commit": "590f5a8b65c0a00b7b0877495da2c6a65428101c", + "url": "https://github.com/kitepon/peertable/actions/runs/36478777216", + "native_os": { + "darwin": "passed", + "linux": "passed", + "win32": "passed" + } + }, + "repair_commit": "bce891b2cbf3a77afa7ab738e19f965e7ac9808c", + "withdrawal_evidence": "rag/parent-delivery/codex-probe-rejoin-diagnosis.json", + "controller_observation_limit_ms": 1500, + "repairs": { + "initial_probe_timeout": { + "state": "focused_and_three_os_ci_passed_live_pending", + "evidence": "rag/parent-delivery/linux-grok-probe-latency-diagnosis.json" + }, + "windows_atomic_replace": { + "state": "diagnostic_focused_and_three_os_ci_passed_native_diagnosis_pending", + "evidence": "rag/parent-delivery/windows-rename-ci-diagnosis.json" + }, + "codex_rejoin": { + "state": "diagnostic_recovered_new_adapter_live_pending", + "evidence": "rag/parent-delivery/codex-probe-rejoin-repair-diagnosis.json" + }, + "cursor_native_input": { + "state": "focused_passed_controller_and_native_verification_pending", + "evidence": "rag/parent-delivery/cursor-native-input-diagnosis.json" + }, + "windows_lock_contention": { + "state": "real_timeout_reproduced_native_identity_adapter_diagnosis_pending", + "evidence": "rag/parent-delivery/windows-lock-contention-diagnosis.json" + } + } +} diff --git a/scripts/parent-delivery-acceptance.mjs b/scripts/parent-delivery-acceptance.mjs index ac126069..04b1a8a1 100644 --- a/scripts/parent-delivery-acceptance.mjs +++ b/scripts/parent-delivery-acceptance.mjs @@ -85,10 +85,22 @@ export function auditAcceptance(records, { sourceCommit, sourceDigest, packageVe for (const id of byId.keys()) if (!expected.some(item => item.id === id)) errors.push({ id, code: 'PARENT_ACCEPTANCE_UNEXPECTED' }) return { schema: 'peertable.parent-acceptance-audit.v1', status: errors.length ? 'failed' : 'passed', expected: expected.length, errors } } +export function approvedReleaseDeferral(decision, { packageVersion, sourceDigest }) { + return decision?.decision === 'release_with_live_acceptance_deferred' + && decision.package_version === packageVersion && decision.runtime_digest === sourceDigest + && typeof decision.owner_instruction === 'string' && decision.owner_instruction.length > 0 +} if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - const [file, commit] = process.argv.slice(2) + const [file, option, decisionFile] = process.argv.slice(2) + const commit = option === '--release-decision' ? undefined : option const manifest = JSON.parse(readFileSync(file, 'utf8')), records = manifest.records try { - const result = auditAcceptance(records, { sourceCommit: testedSourceCommit(records, { sourceCommit: commit ?? manifest.source_commit }), sourceDigest: runtimeDigest(), packageVersion: JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')).version }); console.log(JSON.stringify(result)); if (result.status !== 'passed') process.exitCode = 1 + const sourceDigest = runtimeDigest(), packageVersion = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')).version + const decision = option === '--release-decision' ? JSON.parse(readFileSync(decisionFile, 'utf8')) : null + if (approvedReleaseDeferral(decision, { packageVersion, sourceDigest })) { + console.log(JSON.stringify({ schema: 'peertable.parent-acceptance-audit.v1', status: 'deferred_by_owner', package_version: packageVersion, expected: expectedCases().length, records: records.length, owner_instruction: decision.owner_instruction })) + } else { + const result = auditAcceptance(records, { sourceCommit: testedSourceCommit(records, { sourceCommit: commit ?? manifest.source_commit }), sourceDigest, packageVersion }); console.log(JSON.stringify(result)); if (result.status !== 'passed') process.exitCode = 1 + } } catch (error) { console.log(JSON.stringify({ schema: 'peertable.parent-acceptance-audit.v1', status: 'failed', expected: expectedCases().length, errors: [{ code: error.code ?? 'PARENT_ACCEPTANCE_SOURCE_FAILED', detail: error.message }] })); process.exitCode = 1 } } diff --git a/scripts/parent-delivery-acceptance.test.mjs b/scripts/parent-delivery-acceptance.test.mjs index 0006b119..a3371aea 100644 --- a/scripts/parent-delivery-acceptance.test.mjs +++ b/scripts/parent-delivery-acceptance.test.mjs @@ -1,11 +1,19 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { expectedCases, auditAcceptance, auditScenarioSteps, testedSourceCommit } from './parent-delivery-acceptance.mjs' +import { expectedCases, auditAcceptance, auditScenarioSteps, testedSourceCommit, approvedReleaseDeferral } from './parent-delivery-acceptance.mjs' import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { execFileSync } from 'node:child_process' import { scenarioPlan } from '../experiments/parent-product-acceptance/scenarios.mjs' +test('オーナーの公開裁定は対象versionとruntimeだけに適用し、正式受入を合格にしない', () => { + const decision = { decision: 'release_with_live_acceptance_deferred', package_version: '0.8.63', runtime_digest: 'approved', owner_instruction: '一旦それでリリース、インストールして。' } + assert.equal(approvedReleaseDeferral(decision, { packageVersion: '0.8.63', sourceDigest: 'approved' }), true) + assert.equal(approvedReleaseDeferral(decision, { packageVersion: '0.8.64', sourceDigest: 'approved' }), false) + assert.equal(approvedReleaseDeferral(decision, { packageVersion: '0.8.63', sourceDigest: 'changed' }), false) + assert.equal(approvedReleaseDeferral(null, { packageVersion: '0.8.63', sourceDigest: 'approved' }), false) + assert.equal(auditAcceptance([]).status, 'failed') +}) test('全12組合せの必須実行面は欠落/skip/fixtureを製品実機passedにしない', () => { const expected = expectedCases() assert.equal(expected.length, 525) diff --git a/scripts/prepublish.mjs b/scripts/prepublish.mjs index 27aef3df..514ec08b 100644 --- a/scripts/prepublish.mjs +++ b/scripts/prepublish.mjs @@ -15,5 +15,5 @@ const run = (args) => { } run(['--test', join(root, 'skill/scripts/runtime-contract.test.mjs')]) run([join(root, 'scripts/verify-release-commit.mjs')]) -run([join(root, 'scripts/parent-delivery-acceptance.mjs'), join(root, 'rag/parent-delivery/product-acceptance.json')]) +run([join(root, 'scripts/parent-delivery-acceptance.mjs'), join(root, 'rag/parent-delivery/product-acceptance.json'), '--release-decision', join(root, 'rag/parent-delivery/release-decision.json')]) run([join(root, 'room/client.mjs'), 'diagnostics'])