@logicseek/hello-cc 1.1.0 → 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.
- package/CHANGELOG.md +50 -0
- package/README.md +76 -21
- package/README.zh-CN.md +45 -11
- package/assets/screenshots/web-console.png +0 -0
- package/docs/commands.md +5 -0
- package/docs/commands.zh-CN.md +5 -0
- package/docs/native.md +40 -0
- package/docs/native.zh-CN.md +34 -0
- package/lib/core/peers/tmux-safety.mjs +5 -0
- package/lib/integrations/native/jsonrpc.mjs +45 -11
- package/lib/runtime/native/cli-submissions.mjs +9 -1
- package/lib/runtime/native/service.mjs +3 -0
- package/lib/shared/file-lock.mjs +6 -2
- package/lib/shared/socket-lock-diagnostics.mjs +31 -0
- package/lib/shared/socket-lock-worker.mjs +7 -5
- package/lib/web/context-files.mjs +2 -89
- package/lib/web/http-routes.mjs +4 -4
- package/lib/web/native-sessions.mjs +10 -1
- package/lib/web/project-files.mjs +24 -35
- package/package.json +1 -1
- package/scripts/claude-fork-acceptance.mjs +202 -0
- package/scripts/regression.mjs +65 -11
- package/scripts/web-native-retry-checks.mjs +28 -4
package/scripts/regression.mjs
CHANGED
|
@@ -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
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
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) ||
|
|
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
|
|
1052
|
-
fail('tmux gc --yes
|
|
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
|
|
1055
|
-
fail('tmux gc --yes
|
|
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'
|
|
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()
|
|
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 {
|