@logicseek/hello-cc 1.1.0-rc.9 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,202 @@
1
+ // Actual SDK, CLI subprocess and production adapter; optional local Messages
2
+ // fixture isolates transcript/fork behavior from model recall and credentials.
3
+ import assert from 'node:assert/strict';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import http from 'node:http';
8
+ import { createHash, randomUUID } from 'node:crypto';
9
+ import { createRequire } from 'node:module';
10
+ import { fileURLToPath, pathToFileURL } from 'node:url';
11
+ import { setTimeout as delay } from 'node:timers/promises';
12
+ import { createClaudeAdapter } from '../lib/integrations/native/claude.mjs';
13
+ import { redactSecrets } from '../lib/shared/redact.mjs';
14
+
15
+ const args = process.argv.slice(2);
16
+ if (args.includes('--help') || (!args.includes('--fixture') && !args.includes('--run-live'))) {
17
+ console.log('Usage: node scripts/claude-fork-acceptance.mjs --fixture|--run-live [--claude-package DIR] [--output FILE]');
18
+ console.log('--fixture uses a local Messages server, no model or credentials. --run-live copies existing model routing/auth into a private provider home and consumes quota.');
19
+ process.exit(0);
20
+ }
21
+ const flags = new Set(['--fixture', '--run-live']), values = new Set(['--claude-package', '--output']);
22
+ const options = new Map();
23
+ for (let index = 0; index < args.length; index++) {
24
+ const name = args[index];
25
+ assert.ok(!options.has(name) && (flags.has(name) || values.has(name)), 'Unknown or repeated option');
26
+ if (flags.has(name)) options.set(name, true);
27
+ else { assert.ok(args[index + 1] && !args[index + 1].startsWith('--'), 'Missing option value'); options.set(name, args[++index]); }
28
+ }
29
+ assert.notEqual(Boolean(options.get('--fixture')), Boolean(options.get('--run-live')), 'Select exactly one mode');
30
+ assert.ok(['linux', 'darwin'].includes(process.platform), 'Run inside Linux, macOS or WSL');
31
+ const live = Boolean(options.get('--run-live'));
32
+ const repo = fileURLToPath(new URL('..', import.meta.url));
33
+ const output = path.resolve(options.get('--output') || path.join(os.tmpdir(), 'hcc-claude-fork-' + randomUUID() + '.json'));
34
+ const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-claude-fork-')));
35
+ fs.chmodSync(sandbox, 0o700);
36
+ const root = path.join(sandbox, 'project'), home = path.join(sandbox, 'home'), providerHome = path.join(home, '.claude');
37
+ for (const directory of [root, home, providerHome]) fs.mkdirSync(directory, { mode: 0o700 });
38
+ const hash = value => createHash('sha256').update(value).digest('hex');
39
+ const digest = file => hash(fs.readFileSync(file));
40
+ const sourceFiles = Object.fromEntries(['scripts/claude-fork-acceptance.mjs', 'lib/integrations/native/claude.mjs',
41
+ 'lib/integrations/native/interactions.mjs', 'lib/process/selected-cwd-identity.mjs'].map(name => [name, digest(path.join(repo, name))]));
42
+ const originalEnv = { HOME: process.env.HOME, CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR };
43
+ const settings = path.join(originalEnv.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'settings.json');
44
+ const protectedBefore = fs.existsSync(settings) ? digest(settings) : null;
45
+ const report = { schemaVersion: 1, startedAt: new Date().toISOString(), sourceFiles,
46
+ mode: live ? 'real configured model' : 'deterministic local Messages fixture; actual SDK and CLI subprocess',
47
+ modelCallsRequested: live, realModelCalls: false, queryInjected: false, checks: [], snapshots: [], requests: [], cleanup: {}, success: false,
48
+ limitations: ['Adapter/SDK transcript and recall acceptance; native service ownership guards are covered separately.',
49
+ 'A successful routed model call establishes only the model actually reported, not Anthropic model acceptance.'] };
50
+ const flush = () => { fs.mkdirSync(path.dirname(output), { recursive: true }); fs.writeFileSync(output, JSON.stringify(redactSecrets(report), null, 2) + '\n', { mode: 0o600 }); };
51
+ const check = name => { report.checks.push(name); console.log('PASS ' + name); flush(); };
52
+ const adapters = [], observations = new Map();
53
+ let server, sdk;
54
+ function adapter(name) {
55
+ const events = [];
56
+ const value = createClaudeAdapter({ cwd: root, env: { ...process.env, HCC_SHIM_ENSURED: '1', HCC_SHIM_NO_ATTACH: '1' },
57
+ executorId: randomUUID(), timeoutMs: 15000,
58
+ onEvent(event) {
59
+ if (event.type === 'metadata') report.reportedModel = event.runtimeMetadata?.model || report.reportedModel;
60
+ if (event.type === 'completed') events.push({ type: event.type, turnId: event.turnId, status: event.status, text: event.text });
61
+ } });
62
+ adapters.push({ name, value }); observations.set(value, events); return value;
63
+ }
64
+ async function turn(value, prompt, expected) {
65
+ const sent = await value.send({ text: prompt, submissionId: randomUUID() });
66
+ const deadline = Date.now() + 90000;
67
+ let completion;
68
+ while (!(completion = observations.get(value).find(event => event.turnId === sent.turnId)) && Date.now() < deadline) {
69
+ const state = value.snapshot();
70
+ assert.equal(state.pendingApprovals?.length || 0, 0, 'Nonce-only fork test must never approve tools');
71
+ assert.ok(!state.error, 'Unexpected SDK error during fork acceptance');
72
+ await delay(50);
73
+ }
74
+ assert.ok(completion, 'Fork acceptance turn timed out');
75
+ if (live) report.realModelCalls = true;
76
+ assert.equal(completion.status, 'completed');
77
+ // Persist a non-sensitive actual/expected digest even when the assertion fails.
78
+ report.lastTurn = { sessionId: value.snapshot().sessionId, turnId: sent.turnId,
79
+ actualSha256: hash(completion.text || ''), expectedSha256: hash(expected), matches: completion.text?.trim() === expected };
80
+ flush(); assert.equal(completion.text?.trim(), expected);
81
+ }
82
+ async function history(sessionId, label) {
83
+ const messages = (await sdk.getSessionMessages(sessionId, { dir: root })).filter(message => ['user', 'assistant'].includes(message.type));
84
+ const snapshot = { label, sessionId, messages: messages.length,
85
+ contentHashes: messages.map(message => hash(JSON.stringify({ type: message.type, message: message.message }))),
86
+ messageIds: messages.map(message => message.uuid) };
87
+ report.snapshots.push(snapshot); flush(); return snapshot;
88
+ }
89
+ async function assertCopied(parentId, childId, label) {
90
+ // Capture both immediately after fork and before the child's first query.
91
+ const parent = await history(parentId, label + ': parent'), child = await history(childId, label + ': child before first query');
92
+ assert.ok(parent.messages >= 2, 'Parent transcript must include the seed turn');
93
+ assert.deepEqual(child.contentHashes, parent.contentHashes, 'Fork must copy the whole parent history before recall');
94
+ check(label + ': complete parent/child transcript copy before first child query');
95
+ }
96
+ function fixtureText(messages) {
97
+ const users = messages.filter(message => message.role === 'user').map(message =>
98
+ typeof message.content === 'string' ? message.content : (message.content || []).filter(block => block.type === 'text').map(block => block.text).join('\n'));
99
+ const seed = users.join('\n').match(/HCC_FORK_SEED (HCC_BASE_[a-f0-9]+)\|(HCC_BRANCH_[a-f0-9]+)/);
100
+ let branch = seed?.[2];
101
+ for (const text of users) { const changed = /HCC_FORK_SET (HCC_BRANCH_[a-f0-9]+)/.exec(text); if (changed) branch = changed[1]; }
102
+ report.requests.push({ historyHasSeed: Boolean(seed), userMessages: users.length, branchSha256: branch ? hash(branch) : null });
103
+ return seed ? seed[1] + '|' + branch : 'MISSING_CONTEXT';
104
+ }
105
+ function stream(response, model, text) {
106
+ response.writeHead(200, { 'content-type': 'text/event-stream' });
107
+ const event = (type, value) => response.write('event: ' + type + '\ndata: ' + JSON.stringify({ type, ...value }) + '\n\n');
108
+ event('message_start', { message: { id: 'msg_' + randomUUID().replaceAll('-', ''), type: 'message', role: 'assistant', model,
109
+ content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 0 } } });
110
+ event('content_block_start', { index: 0, content_block: { type: 'text', text: '' } });
111
+ event('content_block_delta', { index: 0, delta: { type: 'text_delta', text } });
112
+ event('content_block_stop', { index: 0 });
113
+ event('message_delta', { delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 1 } });
114
+ event('message_stop', {}); response.end();
115
+ }
116
+ try {
117
+ if (options.get('--claude-package')) {
118
+ const installed = fs.realpathSync(path.resolve(options.get('--claude-package')));
119
+ const pkg = JSON.parse(fs.readFileSync(path.join(installed, 'package.json'), 'utf8'));
120
+ assert.equal(pkg.name, '@anthropic-ai/claude-agent-sdk');
121
+ const scope = path.join(root, 'node_modules', '@anthropic-ai'); fs.mkdirSync(scope, { recursive: true, mode: 0o700 });
122
+ fs.symlinkSync(installed, path.join(scope, 'claude-agent-sdk'), 'dir');
123
+ }
124
+ const entry = createRequire(path.join(root, 'package.json')).resolve('@anthropic-ai/claude-agent-sdk');
125
+ report.sdk = { entrySha256: digest(entry), version: JSON.parse(fs.readFileSync(path.join(path.dirname(entry), 'package.json'), 'utf8')).version };
126
+ process.env.HOME = home; process.env.CLAUDE_CONFIG_DIR = providerHome;
127
+ if (live) {
128
+ if (protectedBefore) {
129
+ const source = JSON.parse(fs.readFileSync(settings, 'utf8'));
130
+ const env = Object.fromEntries(Object.entries(source.env || {}).filter(([key, value]) => key.startsWith('ANTHROPIC_') && typeof value === 'string'));
131
+ fs.writeFileSync(path.join(providerHome, 'settings.json'), JSON.stringify({ env,
132
+ ...(typeof source.model === 'string' ? { model: source.model } : {}) }), { mode: 0o600 });
133
+ }
134
+ } else {
135
+ server = http.createServer(async (request, response) => {
136
+ try {
137
+ assert.equal(request.method, 'POST'); assert.match(request.url, /^\/v1\/messages(?:\?|$)/);
138
+ const chunks = []; let size = 0;
139
+ for await (const chunk of request) { size += chunk.length; assert.ok(size <= 2 * 1024 * 1024); chunks.push(chunk); }
140
+ assert.ok(report.requests.length < 10, 'Fixture request bound exceeded');
141
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
142
+ const text = fixtureText(body.messages);
143
+ if (body.stream) stream(response, body.model, text);
144
+ else { response.writeHead(200, { 'content-type': 'application/json' }); response.end(JSON.stringify({ id: 'msg_' + randomUUID(), type: 'message', role: 'assistant', model: body.model,
145
+ content: [{ type: 'text', text }], stop_reason: 'end_turn', stop_sequence: null, usage: { input_tokens: 1, output_tokens: 1 } })); }
146
+ } catch { response.writeHead(500); response.end(JSON.stringify({ error: { type: 'api_error', message: 'Fixture protocol failure' } })); }
147
+ });
148
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
149
+ fs.writeFileSync(path.join(providerHome, 'settings.json'), JSON.stringify({ model: 'claude-sonnet-4-5-20250929', env: {
150
+ ANTHROPIC_BASE_URL: 'http://127.0.0.1:' + server.address().port, ANTHROPIC_API_KEY: 'local-fixture-no-credential',
151
+ ANTHROPIC_AUTH_TOKEN: '', CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' } }), { mode: 0o600 });
152
+ }
153
+ sdk = await import(pathToFileURL(entry).href);
154
+ assert.equal(typeof sdk.forkSession, 'function'); assert.equal(typeof sdk.getSessionMessages, 'function');
155
+ const base = 'HCC_BASE_' + randomUUID().replaceAll('-', '').slice(0, 16);
156
+ const initial = 'HCC_BRANCH_' + randomUUID().replaceAll('-', '').slice(0, 16);
157
+ const changed = 'HCC_BRANCH_' + randomUUID().replaceAll('-', '').slice(0, 16);
158
+ const recall = 'HCC_FORK_RECALL: Without tools or reading files, reply only with the full base|branch values established earlier, including the original HCC_BASE_ and HCC_BRANCH_ prefixes and their hexadecimal suffixes. Do not infer or invent missing values.';
159
+ const parent = adapter('parent'); await parent.open();
160
+ await turn(parent, 'HCC_FORK_SEED ' + base + '|' + initial + '. Remember both complete values in this conversation. Reply with exactly this complete string including both HCC_ prefixes: ' + base + '|' + initial + '. Do not use tools or read files.', base + '|' + initial);
161
+ const parentId = parent.snapshot().sessionId;
162
+ const child = adapter('child'); await child.open({ forkSessionId: parentId });
163
+ const childId = child.snapshot().sessionId; assert.ok(childId && childId !== parentId);
164
+ await assertCopied(parentId, childId, 'open-parent fork');
165
+ if (!live) assert.equal(report.requests.length, 1, 'Fork must not perform hidden inference');
166
+ await turn(child, recall, base + '|' + initial); check('first child turn recalls both copied values without prompt replay');
167
+ await turn(child, 'HCC_FORK_SET ' + changed + '. Replace only the branch value, keep the original full base value, and reply only with base|branch including the HCC_BASE_ and HCC_BRANCH_ prefixes and hexadecimal suffixes. No tools or file reads.', base + '|' + changed);
168
+ const parentBefore = await history(parentId, 'parent after child-only branch change');
169
+ await turn(parent, recall, base + '|' + initial); check('child branch update leaves parent model-visible values intact');
170
+ const childBeforeResume = await history(childId, 'child before close/resume');
171
+ assert.ok(childBeforeResume.messages > parentBefore.messages);
172
+ await child.close();
173
+ const resumed = adapter('resumed-child'); await resumed.open({ sessionId: childId });
174
+ await turn(resumed, recall, base + '|' + changed);
175
+ assert.equal(resumed.snapshot().sessionId, childId); check('child close/resume retains its own identity and changed branch');
176
+ await parent.close();
177
+ const closedFork = adapter('closed-parent-child'); await closedFork.open({ forkSessionId: parentId });
178
+ const closedForkId = closedFork.snapshot().sessionId;
179
+ assert.ok(closedForkId && closedForkId !== childId && closedForkId !== parentId);
180
+ await assertCopied(parentId, closedForkId, 'closed-parent fork');
181
+ await turn(closedFork, recall, base + '|' + initial); check('closed-parent fork recalls the original branch with a distinct identity');
182
+ report.completed = true;
183
+ } catch (error) {
184
+ report.error = { code: error.code || 'ACCEPTANCE_FAILED', message: redactSecrets(error.message).slice(0, 1500) };
185
+ if (sdk?.getSessionMessages) for (const { name, value } of adapters) {
186
+ const sessionId = value.snapshot().sessionId;
187
+ if (sessionId) try { await history(sessionId, 'failure: ' + name); } catch { /* Preserve earlier snapshots. */ }
188
+ }
189
+ } finally {
190
+ const closed = await Promise.allSettled(adapters.map(({ value }) => value.close()));
191
+ report.cleanup.adaptersClosed = closed.every(value => value.status === 'fulfilled');
192
+ if (server) { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); }
193
+ for (const [key, value] of Object.entries(originalEnv)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; }
194
+ report.protectedSettingsUnchanged = protectedBefore === null ? !fs.existsSync(settings) : fs.existsSync(settings) && digest(settings) === protectedBefore;
195
+ report.sourceChanges = Object.entries(sourceFiles).filter(([name, before]) => digest(path.join(repo, name)) !== before).map(([name]) => name);
196
+ try { fs.rmSync(sandbox, { recursive: true, force: true }); report.cleanup.privateProjectAndHomeRemoved = !fs.existsSync(sandbox); }
197
+ catch { report.cleanup.privateProjectAndHomeRemoved = false; }
198
+ report.success = report.completed === true && !report.error && report.cleanup.adaptersClosed && report.cleanup.privateProjectAndHomeRemoved && report.protectedSettingsUnchanged && !report.sourceChanges.length;
199
+ report.finishedAt = new Date().toISOString(); flush();
200
+ }
201
+ console.log(JSON.stringify({ output, success: report.success, realModelCalls: report.realModelCalls, checks: report.checks.length, error: report.error, cleanup: report.cleanup }));
202
+ process.exitCode = report.success ? 0 : 1;
@@ -57,6 +57,7 @@ const fixtureFailures = new WeakSet();
57
57
  const fixtureCommandFailures = new WeakMap();
58
58
  const fixtureHttpFailures = new WeakMap();
59
59
  const fixtureShimRegistrationFailures = new WeakMap();
60
+ const fixtureTmuxGcFailures = new WeakMap();
60
61
  const managedTmuxSessions = new Set();
61
62
 
62
63
  const env = {
@@ -102,6 +103,9 @@ export function fixtureFailureError(message, commandResult = null, diagnosticOpe
102
103
  if (diagnosticOperation === 'shim-registration') {
103
104
  fixtureShimRegistrationFailures.set(error, fixtureShimRegistrationSummary(commandResult));
104
105
  }
106
+ if (diagnosticOperation === 'tmux-gc') {
107
+ fixtureTmuxGcFailures.set(error, fixtureTmuxGcSummary(commandResult));
108
+ }
105
109
  return error;
106
110
  }
107
111
 
@@ -117,10 +121,32 @@ export function fixtureFailureDiagnostic(error) {
117
121
  ...(fixtureHttpFailures.has(error) ? { http: fixtureHttpFailures.get(error) } : {}),
118
122
  ...(fixtureShimRegistrationFailures.has(error)
119
123
  ? { shimRegistration: fixtureShimRegistrationFailures.get(error) } : {}),
124
+ ...(fixtureTmuxGcFailures.has(error) ? { tmuxGc: fixtureTmuxGcFailures.get(error) } : {}),
120
125
  ...(terminalMarker ? { terminalMarker } : {})
121
126
  };
122
127
  }
123
128
 
129
+ export function fixtureTmuxGcSummary(observation = {}) {
130
+ const reasons = ['stale_hcc_managed_session', 'stale_rebind_cleanup_failed_session',
131
+ 'tmux_stored_evidence_incomplete', 'tmux_session_changed', 'tmux_session_reused',
132
+ 'tmux_session_id_changed', 'tmux_root_unknown', 'tmux_root_changed', 'tmux_pane_changed',
133
+ 'tmux_pane_not_dead', 'tmux_dead_process_pid_unknown', 'tmux_dead_process_pid_changed', 'tmux_process_not_confirmed_dead',
134
+ 'tmux_process_changed', 'tmux_clients_unknown', 'tmux_has_clients',
135
+ 'tmux_binding_subject_incomplete', 'tmux_binding_session_authority_changed',
136
+ 'tmux_binding_root_authority_changed', 'tmux_binding_pane_authority_changed',
137
+ 'tmux_owner_process_live', 'tmux_owner_process_incompatible', 'tmux_target_missing',
138
+ 'not_hcc_managed_name', 'hcc_root_mismatch', 'duplicate_db_binding', 'runtime_managed',
139
+ 'not_old_enough', 'old_runtime_target_changed'];
140
+ return Object.freeze(Object.fromEntries(['stale', 'peerFilter', 'deadBinding', 'deadEvent'].map(role => {
141
+ const value = observation?.[role];
142
+ return [role, Object.freeze({
143
+ candidate: typeof value?.candidate === 'boolean' ? value.candidate : null,
144
+ reason: reasons.includes(value?.reason) ? value.reason : 'unknown',
145
+ mode: ['dead_process', 'explicit_exit_live'].includes(value?.mode) ? value.mode : null
146
+ })];
147
+ })));
148
+ }
149
+
124
150
  export function fixtureShimRegistrationSummary(observation = {}) {
125
151
  // Public annotations retain only fixture-owned booleans and finite enums.
126
152
  // CLI tables, provider output, identifiers, paths and environment are excluded.
@@ -983,6 +1009,17 @@ async function assertTmuxGcPolicy() {
983
1009
  await waitFor(() => run('tmux', ['display-message', '-p', '-t', deadEventPane, '#{pane_dead}']).trim() === '1', 'dead event pane');
984
1010
  await waitFor(() => run('tmux', ['display-message', '-p', '-t', clientUnknownEventPane, '#{pane_dead}']).trim() === '1', 'client-unknown dead event pane');
985
1011
  await waitFor(() => run('tmux', ['display-message', '-p', '-t', deadBindingPane, '#{pane_dead}']).trim() === '1', 'dead binding pane');
1012
+ // Recent tmux hides pane_pid after PTY closure. Keep the destructive guard
1013
+ // strict: test deletion only when this host still supplies the original PID,
1014
+ // and require an explicit safe skip otherwise. Never substitute a saved PID.
1015
+ const retainsDeadPanePid = (pane, evidence) => {
1016
+ const pid = Number(run('tmux', ['display-message', '-p', '-t', pane, '#{pane_pid}']).trim());
1017
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
1018
+ if (pid !== evidence.old_process_identity.pid) fail('dead tmux fixture process changed before GC');
1019
+ return true;
1020
+ };
1021
+ const deadBindingGcAvailable = retainsDeadPanePid(deadBindingPane, deadBindingEvidence);
1022
+ const deadEventGcAvailable = retainsDeadPanePid(deadEventPane, deadEventEvidence);
986
1023
 
987
1024
  const attachTmux = realTmuxBin ? `${sh(realTmuxBin)} -L ${sh(tmuxSocketName)}` : 'tmux';
988
1025
  const client = spawnSync('tmux', ['new-session', '-d', '-s', `${tmuxSession}-gc-client`, '-e', `HCC_ROOT=${root}`, 'sh', '-lc', `unset TMUX; exec ${attachTmux} attach-session -t ${sh(attachedSession)}`], {
@@ -1000,21 +1037,35 @@ async function assertTmuxGcPolicy() {
1000
1037
  HCC_REGRESSION_TMUX_FAIL_CLIENT_SESSIONS: [clientUnknownEventSession, clientUnknownBindingSession].join(' ')
1001
1038
  };
1002
1039
  const dryRun = hcc(['tmux', 'gc', '--older-than', '14'], { env: gcEnv });
1040
+ const dryRunJson = hccJson(['tmux', 'gc', '--older-than', '14'], { env: gcEnv });
1041
+ const gcObservation = Object.fromEntries(Object.entries({
1042
+ stale: stalePeer, peerFilter: peerFilterPeer, deadBinding: deadBindingPeer, deadEvent: deadEventPeer
1043
+ }).map(([role, peer]) => {
1044
+ const candidate = dryRunJson.candidates.find(row => row.peer === peer);
1045
+ const row = candidate || dryRunJson.skipped.find(row => row.peer === peer);
1046
+ return [role, { candidate: Boolean(candidate), reason: row?.reason, mode: row?.gc_validation_mode }];
1047
+ }));
1003
1048
  if (!gcOutputHasPeer(dryRun, stalePeer) || !dryRun.includes('stale_hcc_managed_session') ||
1004
1049
  !gcOutputHasPeer(dryRun, peerFilterPeer) ||
1005
- !gcOutputHasPeer(dryRun, deadBindingPeer) ||
1006
- !gcOutputHasPeer(dryRun, deadEventPeer) || !dryRun.includes('stale_rebind_cleanup_failed_session')) {
1007
- fail(`tmux gc dry-run did not list stale DB-proven managed session:\n${dryRun}`);
1050
+ gcOutputHasPeer(dryRun, deadBindingPeer) !== deadBindingGcAvailable ||
1051
+ gcOutputHasPeer(dryRun, deadEventPeer) !== deadEventGcAvailable ||
1052
+ (deadEventGcAvailable && !dryRun.includes('stale_rebind_cleanup_failed_session'))) {
1053
+ fail(`tmux gc dry-run did not list stale DB-proven managed session:\n${dryRun}\n${JSON.stringify(gcObservation)}`,
1054
+ gcObservation, 'tmux-gc');
1055
+ }
1056
+ for (const [peer, available] of [[deadBindingPeer, deadBindingGcAvailable], [deadEventPeer, deadEventGcAvailable]]) {
1057
+ if (!available && !dryRunJson.skipped.some(row => row.peer === peer && row.reason === 'tmux_dead_process_pid_unknown')) {
1058
+ fail('tmux gc must retain dead panes whose process binding is unavailable', gcObservation, 'tmux-gc');
1059
+ }
1008
1060
  }
1009
1061
  for (const protectedPeer of [reusedBindingPeer, clientUnknownBindingPeer, eventPeer, reusedEventPeer, reusedProcessEventPeer, clientUnknownEventPeer, rootEventPeer, legacyEventPeer]) {
1010
1062
  if (gcOutputHasPeer(dryRun, protectedPeer)) {
1011
1063
  fail(`tmux gc dry-run treated live/unknown event evidence as removable (${protectedPeer}):\n${dryRun}`);
1012
1064
  }
1013
1065
  }
1014
- const dryRunJson = hccJson(['tmux', 'gc', '--older-than', '14'], { env: gcEnv });
1015
1066
  const candidateModes = new Map(dryRunJson.candidates.map((candidate) => [candidate.peer, candidate.gc_validation_mode]));
1016
1067
  if (candidateModes.get(stalePeer) !== 'explicit_exit_live' ||
1017
- candidateModes.get(deadBindingPeer) !== 'dead_process') {
1068
+ candidateModes.get(deadBindingPeer) !== (deadBindingGcAvailable ? 'dead_process' : undefined)) {
1018
1069
  fail(`tmux gc did not record strict binding validation modes:\n${JSON.stringify(dryRunJson.candidates, null, 2)}`);
1019
1070
  }
1020
1071
  if (runMaybe('tmux', ['has-session', '-t', staleSession]).status !== 0) {
@@ -1037,7 +1088,9 @@ async function assertTmuxGcPolicy() {
1037
1088
  }
1038
1089
 
1039
1090
  const removed = hcc(['tmux', 'gc', '--older-than', '14', '--yes'], { env: gcEnv });
1040
- if (!gcOutputHasPeer(removed, stalePeer) || !gcOutputHasPeer(removed, deadBindingPeer) || !gcOutputHasPeer(removed, deadEventPeer) ||
1091
+ if (!gcOutputHasPeer(removed, stalePeer) ||
1092
+ gcOutputHasPeer(removed, deadBindingPeer) !== deadBindingGcAvailable ||
1093
+ gcOutputHasPeer(removed, deadEventPeer) !== deadEventGcAvailable ||
1041
1094
  gcOutputHasPeer(removed, reusedBindingPeer) || gcOutputHasPeer(removed, clientUnknownBindingPeer) ||
1042
1095
  gcOutputHasPeer(removed, eventPeer) || gcOutputHasPeer(removed, reusedEventPeer) ||
1043
1096
  gcOutputHasPeer(removed, reusedProcessEventPeer) || gcOutputHasPeer(removed, clientUnknownEventPeer) ||
@@ -1048,11 +1101,11 @@ async function assertTmuxGcPolicy() {
1048
1101
  if (runMaybe('tmux', ['has-session', '-t', staleSession]).status === 0) {
1049
1102
  fail('tmux gc --yes did not delete stale DB-proven managed session');
1050
1103
  }
1051
- if (runMaybe('tmux', ['has-session', '-t', deadBindingSession]).status === 0) {
1052
- fail('tmux gc --yes did not delete a truly dead exact binding session');
1104
+ if ((runMaybe('tmux', ['has-session', '-t', deadBindingSession]).status !== 0) !== deadBindingGcAvailable) {
1105
+ fail('tmux gc --yes must delete only a truly dead exact binding session with its original PID');
1053
1106
  }
1054
- if (runMaybe('tmux', ['has-session', '-t', deadEventSession]).status === 0) {
1055
- fail('tmux gc --yes did not delete a confirmed-dead rebind cleanup session listed by dry-run');
1107
+ if ((runMaybe('tmux', ['has-session', '-t', deadEventSession]).status !== 0) !== deadEventGcAvailable) {
1108
+ fail('tmux gc --yes must retain rebind cleanup sessions lacking a confirmed process binding');
1056
1109
  }
1057
1110
  for (const protectedSession of [reusedBindingSession, clientUnknownBindingSession, eventSession, reusedEventSession, reusedProcessEventSession, clientUnknownEventSession, rootEventSession, manualSession]) {
1058
1111
  if (runMaybe('tmux', ['has-session', '-t', protectedSession]).status !== 0) {
@@ -1087,7 +1140,8 @@ async function assertTmuxGcPolicy() {
1087
1140
  fail(`tmux gc changed attached-client binding:\n${JSON.stringify(attachedBinding, null, 2)}`);
1088
1141
  }
1089
1142
  const deadBinding = peerBindingRow(deadBindingPeer);
1090
- if (!deadBinding || deadBinding.transport !== 'detached' || deadBinding.runtime_target !== null) {
1143
+ if (!deadBinding || deadBinding.transport !== (deadBindingGcAvailable ? 'detached' : 'tmux') ||
1144
+ deadBinding.runtime_target !== (deadBindingGcAvailable ? null : deadBindingPane)) {
1091
1145
  fail(`tmux gc did not detach truly dead binding:\n${JSON.stringify(deadBinding, null, 2)}`);
1092
1146
  }
1093
1147
  for (const [peer, pane] of [
@@ -5,6 +5,21 @@ import { DatabaseSync } from 'node:sqlite';
5
5
 
6
6
  const FAILURE_CODE = 'NATIVE_ACCEPTANCE_COMMIT_GAP';
7
7
  const hash = text => createHash('sha256').update(text).digest('hex');
8
+
9
+ // Retain enough to distinguish a conflict from a lost response without
10
+ // writing response text, drafts, authorization headers or action tokens.
11
+ export function summarizeNativeRetryResponse(status, body) {
12
+ const bytes = Buffer.from(body);
13
+ const summary = { status, bytes: bytes.length, bodySha256: hash(bytes), format: 'non-json', code: null };
14
+ if (bytes.length <= 65536) {
15
+ try {
16
+ const value = JSON.parse(bytes.toString('utf8'));
17
+ summary.format = 'json';
18
+ if (typeof value?.error?.code === 'string' && /^[A-Z][A-Z0-9_]{0,79}$/.test(value.error.code)) summary.code = value.error.code;
19
+ } catch { /* The hash still identifies an HTML, empty or malformed response. */ }
20
+ } else summary.format = 'oversized';
21
+ return summary;
22
+ }
8
23
  function readMesh(dbPath, read) {
9
24
  const db = new DatabaseSync(dbPath, { readOnly: true });
10
25
  try { return read(db); } finally { db.close(); }
@@ -62,12 +77,17 @@ export async function runNativeRetryChecks({ browser, base, token, root, dbPath,
62
77
  const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
63
78
  await context.addInitScript(browserInstrumentation);
64
79
  const page = await context.newPage(); watch(page);
65
- const requests = [], responses = [];
80
+ const requests = [], responses = [], responseDetails = [], responseReads = [];
66
81
  page.on('request', request => {
67
82
  if (request.method() === 'POST' && new URL(request.url()).pathname === route) requests.push(request.postDataJSON());
68
83
  });
69
84
  page.on('response', response => {
70
- if (response.request().method() === 'POST' && new URL(response.url()).pathname === route) responses.push(response.status());
85
+ if (response.request().method() !== 'POST' || new URL(response.url()).pathname !== route) return;
86
+ responses.push(response.status());
87
+ const summary = { status: response.status(), format: 'unavailable', code: null };
88
+ responseDetails.push(summary);
89
+ responseReads.push(response.body().then(body => Object.assign(summary, summarizeNativeRetryResponse(response.status(), body)))
90
+ .catch(() => { summary.format = 'unavailable'; }));
71
91
  });
72
92
  let observerContext, workerStarted = false, failure;
73
93
  try {
@@ -147,17 +167,21 @@ export async function runNativeRetryChecks({ browser, base, token, root, dbPath,
147
167
  await shot(page, viewport.name + '-native-retry-reconciled');
148
168
  assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2), true);
149
169
  assert.deepEqual(responses, [409, 200]);
170
+ await Promise.all(responseReads);
171
+ assert.equal(responseDetails[0].code, FAILURE_CODE);
172
+ assert.equal(responseDetails[1].format, 'json');
150
173
  receipt.scenarios.push({ viewport, peer, submissionId, messageId: message.message_id,
151
174
  owner: originalState.owner, generation: originalState.generation,
152
- requestCount: requests.length, responseStatuses: responses, expectedFailure: FAILURE_CODE,
175
+ requestCount: requests.length, responseStatuses: responses, responseDetails, expectedFailure: FAILURE_CODE,
153
176
  originalBodySha256: hash(original), laterDraftPreserved: true, observerRetryBlocked: true,
154
177
  messages: counts.messages, durableIntents: counts.intents, pendingEvents: counts.pendingEvents,
155
178
  deliveries: active.deliveries.length, userOriginRetained: true, providerInputs: adapter.sent });
156
179
  check('native retry ' + viewport.name + ': original durable submission repairs once; edited draft and observer fence survive');
157
180
  } catch (error) {
158
181
  failure = error;
182
+ await Promise.all(responseReads);
159
183
  receipt.failure = { viewport: viewport.name, peer, message: error.message, requestCount: requests.length,
160
- responseStatuses: responses, injected: retryFault.injected.filter(value => value.peer === peer) };
184
+ responseStatuses: responses, responseDetails, injected: retryFault.injected.filter(value => value.peer === peer) };
161
185
  try { await shot(page, viewport.name + '-native-retry-failure'); } catch { /* Preserve the original assertion. */ }
162
186
  throw error;
163
187
  } finally {