@logicseek/hello-cc 0.1.9 → 1.0.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 +136 -0
- package/README.md +50 -3
- package/README.zh-CN.md +48 -3
- package/bin/hcc.mjs +3 -5656
- package/docs/README.md +8 -0
- package/docs/README.zh-CN.md +6 -0
- package/docs/architecture.md +29 -18
- package/docs/architecture.zh-CN.md +20 -10
- package/docs/commands.md +13 -4
- package/docs/commands.zh-CN.md +13 -4
- package/docs/guide.md +97 -3
- package/docs/guide.zh-CN.md +89 -2
- package/lib/cli/app.mjs +644 -0
- package/lib/cli/commands/coordination.mjs +249 -0
- package/lib/cli/commands/doctor.mjs +81 -0
- package/lib/cli/commands/gc.mjs +1002 -0
- package/lib/cli/commands/hook.mjs +205 -0
- package/lib/cli/commands/install.mjs +294 -0
- package/lib/cli/commands/lifecycle.mjs +101 -0
- package/lib/cli/commands/lock.mjs +197 -0
- package/lib/cli/commands/misc.mjs +76 -0
- package/lib/cli/commands/msg.mjs +152 -0
- package/lib/cli/commands/peer.mjs +249 -0
- package/lib/cli/commands/query.mjs +96 -0
- package/lib/cli/commands/run.mjs +463 -0
- package/lib/cli/commands/task.mjs +405 -0
- package/lib/cli/commands/team.mjs +131 -0
- package/lib/cli/commands/tmux.mjs +838 -0
- package/lib/cli/commands/up.mjs +135 -0
- package/lib/cli-args.mjs +16 -2
- package/lib/coordination-state.mjs +112 -51
- package/lib/core/coordination/automation.mjs +5 -8
- package/lib/core/coordination/clock-safety.mjs +323 -0
- package/lib/core/coordination/event-retention.mjs +17 -0
- package/lib/core/coordination/gc-plan.mjs +614 -0
- package/lib/core/coordination/lease-renewal.mjs +102 -0
- package/lib/core/coordination/lock-evidence.mjs +77 -0
- package/lib/core/coordination/optimistic-evidence.mjs +26 -0
- package/lib/core/coordination/tasks.mjs +156 -36
- package/lib/core/peers/evidence-runtime.mjs +312 -0
- package/lib/core/peers/evidence.mjs +103 -0
- package/lib/core/peers/hook-owner.mjs +32 -0
- package/lib/core/peers/liveness.mjs +44 -4
- package/lib/core/peers/peer-helpers.mjs +121 -0
- package/lib/core/peers/session.mjs +8 -2
- package/lib/core/peers/tmux-safety.mjs +290 -0
- package/lib/core/sessions/launch.mjs +13 -1
- package/lib/db/connection.mjs +104 -0
- package/lib/db/events.mjs +34 -0
- package/lib/db/migration-backup.mjs +225 -0
- package/lib/db/schema.mjs +102 -5
- package/lib/db/stores/peers.mjs +34 -0
- package/lib/integrations/peers/identity.mjs +62 -16
- package/lib/integrations/shims/script.mjs +111 -9
- package/lib/process/identity.mjs +222 -0
- package/lib/process/pty-lifecycle.mjs +149 -0
- package/lib/runtime/buffer-directory-lease.mjs +48 -0
- package/lib/runtime/buffer-evidence.mjs +336 -0
- package/lib/runtime/buffer-gc-protocol.mjs +167 -0
- package/lib/runtime/buffer-gc.mjs +270 -0
- package/lib/runtime/client.mjs +63 -7
- package/lib/runtime/fatal-shutdown.mjs +54 -0
- package/lib/runtime/project-path.mjs +139 -0
- package/lib/runtime/projects.mjs +320 -40
- package/lib/runtime/state.mjs +227 -27
- package/lib/shared/cleanup-error.mjs +54 -0
- package/lib/shared/clock-grace.mjs +106 -0
- package/lib/shared/errors.mjs +9 -0
- package/lib/shared/file-lock.mjs +377 -0
- package/lib/shared/json-file.mjs +6 -2
- package/lib/shared/redact.mjs +60 -0
- package/lib/shared/socket-lock-worker.mjs +250 -0
- package/lib/terminal/tmux.mjs +131 -27
- package/lib/tmux.mjs +4 -0
- package/lib/ui/help.mjs +53 -15
- package/lib/web/api-version.mjs +42 -0
- package/lib/web/auto-attach.mjs +185 -0
- package/lib/web/buffer-gc-runtime.mjs +175 -0
- package/lib/web/cookie-auth.mjs +119 -0
- package/lib/web/external-sessions.mjs +254 -0
- package/lib/web/http-routes.mjs +542 -0
- package/lib/web/http.mjs +135 -6
- package/lib/web/liveness-reaper.mjs +145 -0
- package/lib/web/peer-actions.mjs +132 -23
- package/lib/web/project-contexts.mjs +183 -0
- package/lib/web/pty-sessions.mjs +203 -0
- package/lib/web/runtime-main.mjs +717 -0
- package/lib/web/runtime.mjs +91 -36
- package/lib/web/session-serialize.mjs +0 -0
- package/lib/web/startup.mjs +335 -0
- package/lib/web/tls.mjs +365 -0
- package/lib/web/tmux-sessions.mjs +1361 -0
- package/lib/web/tmux-stream.mjs +166 -0
- package/lib/web/ui-template.mjs +152 -20
- package/package.json +6 -3
- package/scripts/check-free-identifiers.mjs +290 -0
- package/scripts/regression.mjs +5404 -273
- package/docs/architecture-split-review.md +0 -217
- package/docs/layout-split-stack-audit.md +0 -298
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// Runtime evidence helpers extracted from bin/hcc.mjs.
|
|
2
|
+
// Process/tmux evidence observation, optimistic evidence-checked peer
|
|
3
|
+
// mutation, and clock-safety observation adapters shared by web + GC.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { tx } from '../../db/schema.mjs';
|
|
8
|
+
import {
|
|
9
|
+
clockSafetyUnavailable,
|
|
10
|
+
observeClockSafety,
|
|
11
|
+
observeClockSafetyInTransaction
|
|
12
|
+
} from '../../core/coordination/clock-safety.mjs';
|
|
13
|
+
import {
|
|
14
|
+
captureLockAcquireSubject,
|
|
15
|
+
clockCandidatesFromLocks,
|
|
16
|
+
observeLockOwnerEvidence
|
|
17
|
+
} from '../../core/coordination/lock-evidence.mjs';
|
|
18
|
+
import { resolvePeerEvidence } from '../../core/peers/evidence.mjs';
|
|
19
|
+
import { inspectProcessIdentity } from '../../process/identity.mjs';
|
|
20
|
+
import {
|
|
21
|
+
runTmux,
|
|
22
|
+
tmuxManagedSessionName,
|
|
23
|
+
tmuxManagedSessionNameMatches,
|
|
24
|
+
tmuxManagedSessionPrefixMatches,
|
|
25
|
+
tmuxPaneInfo,
|
|
26
|
+
tmuxSessionEnvironmentValue
|
|
27
|
+
} from '../../tmux.mjs';
|
|
28
|
+
|
|
29
|
+
export function createEvidenceRuntime({ now }) {
|
|
30
|
+
function isProjectManagedTmuxSession(projectCtx, sessionName, sessionRoot = null) {
|
|
31
|
+
return tmuxManagedSessionPrefixMatches(projectCtx, sessionName, sessionRoot);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function liveProcessIdentity(pid) {
|
|
35
|
+
const inspected = inspectProcessIdentity(pid);
|
|
36
|
+
return inspected.state === 'live' ? inspected.identity : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function storedPeerIdentity(row) {
|
|
40
|
+
return row?.pid ? {
|
|
41
|
+
pid: Number(row.pid),
|
|
42
|
+
startToken: row.pid_start_token,
|
|
43
|
+
commandHash: row.pid_command_hash
|
|
44
|
+
} : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function processEvidenceFromRow(row, name = 'peer') {
|
|
48
|
+
return {
|
|
49
|
+
name,
|
|
50
|
+
storedIdentity: storedPeerIdentity(row),
|
|
51
|
+
current: inspectProcessIdentity(row?.pid)
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function canonicalRoot(value) {
|
|
56
|
+
if (!value) return null;
|
|
57
|
+
try { return fs.realpathSync(value); }
|
|
58
|
+
catch {
|
|
59
|
+
try { return path.resolve(value); } catch { return null; }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function rootEvidence(expected, actual) {
|
|
64
|
+
const expectedRoot = canonicalRoot(expected);
|
|
65
|
+
const actualRoot = canonicalRoot(actual);
|
|
66
|
+
if (!expectedRoot || !actualRoot) {
|
|
67
|
+
return { state: 'unknown', expected: expectedRoot, actual: actualRoot };
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
state: expectedRoot === actualRoot ? 'match' : 'mismatch',
|
|
71
|
+
expected: expectedRoot,
|
|
72
|
+
actual: actualRoot
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function tmuxTargetMissing(error) {
|
|
77
|
+
return /can't find pane|can't find session|no server running/i.test(String(error?.message || ''));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function inspectTmuxTarget(expectedSession, target) {
|
|
81
|
+
let actualSession = null;
|
|
82
|
+
try {
|
|
83
|
+
actualSession = runTmux(['display-message', '-p', '-t', target, '#{session_name}']).trim() || null;
|
|
84
|
+
} catch (targetError) {
|
|
85
|
+
try {
|
|
86
|
+
runTmux(['has-session', '-t', expectedSession]);
|
|
87
|
+
actualSession = expectedSession;
|
|
88
|
+
} catch (sessionError) {
|
|
89
|
+
return {
|
|
90
|
+
session: { state: tmuxTargetMissing(sessionError) ? 'dead' : 'unknown', expected: expectedSession, actual: null },
|
|
91
|
+
pane: { state: tmuxTargetMissing(targetError) ? 'dead' : 'unknown', expected: target, actual: null },
|
|
92
|
+
paneInfo: null
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!actualSession) {
|
|
97
|
+
try {
|
|
98
|
+
runTmux(['has-session', '-t', expectedSession]);
|
|
99
|
+
actualSession = expectedSession;
|
|
100
|
+
} catch (sessionError) {
|
|
101
|
+
return {
|
|
102
|
+
session: { state: tmuxTargetMissing(sessionError) ? 'dead' : 'unknown', expected: expectedSession, actual: null },
|
|
103
|
+
pane: { state: 'unknown', expected: target, actual: null },
|
|
104
|
+
paneInfo: null
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const paneInfo = tmuxPaneInfo(target);
|
|
111
|
+
const expectedPane = target === `${expectedSession}:0.0` ? paneInfo.pane : target;
|
|
112
|
+
return {
|
|
113
|
+
session: { state: 'live', expected: expectedSession, actual: actualSession },
|
|
114
|
+
pane: { state: paneInfo.dead ? 'dead' : 'live', expected: expectedPane, actual: paneInfo.pane },
|
|
115
|
+
paneInfo
|
|
116
|
+
};
|
|
117
|
+
} catch (error) {
|
|
118
|
+
return {
|
|
119
|
+
session: { state: 'live', expected: expectedSession, actual: actualSession },
|
|
120
|
+
pane: { state: tmuxTargetMissing(error) ? 'dead' : 'unknown', expected: target, actual: null },
|
|
121
|
+
paneInfo: null
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function observePeerEvidence(projectCtx, row, binding = null) {
|
|
127
|
+
if (binding?.transport !== 'tmux') {
|
|
128
|
+
return resolvePeerEvidence({ peer: row, processes: [processEvidenceFromRow(row)] });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const expectedSession = tmuxManagedSessionName(projectCtx, row.id);
|
|
132
|
+
const runtimeTarget = binding.runtime_target || `${expectedSession}:0.0`;
|
|
133
|
+
const target = inspectTmuxTarget(expectedSession, runtimeTarget);
|
|
134
|
+
const panePid = target.paneInfo?.pid || row.pid;
|
|
135
|
+
const paneProcess = {
|
|
136
|
+
name: 'pane',
|
|
137
|
+
storedIdentity: Number(row?.pid) === Number(panePid) ? storedPeerIdentity(row) : null,
|
|
138
|
+
current: inspectProcessIdentity(panePid)
|
|
139
|
+
};
|
|
140
|
+
const actualRoot = target.session.actual
|
|
141
|
+
? tmuxSessionEnvironmentValue(target.session.actual, 'HCC_ROOT')
|
|
142
|
+
: null;
|
|
143
|
+
if (target.session.actual && tmuxManagedSessionNameMatches(
|
|
144
|
+
projectCtx,
|
|
145
|
+
target.session.actual,
|
|
146
|
+
row.id,
|
|
147
|
+
actualRoot
|
|
148
|
+
)) {
|
|
149
|
+
target.session.expected = target.session.actual;
|
|
150
|
+
}
|
|
151
|
+
return resolvePeerEvidence({
|
|
152
|
+
peer: row,
|
|
153
|
+
processes: [processEvidenceFromRow(row, 'owner')],
|
|
154
|
+
tmux: {
|
|
155
|
+
managed: true,
|
|
156
|
+
session: target.session,
|
|
157
|
+
pane: target.pane,
|
|
158
|
+
root: rootEvidence(projectCtx.root, actualRoot),
|
|
159
|
+
process: paneProcess
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function peerEvidenceFromDb(db, projectCtx, peerId) {
|
|
165
|
+
const row = db.prepare(`
|
|
166
|
+
SELECT id, status, pid, pid_start_token, pid_command_hash
|
|
167
|
+
FROM peers WHERE id = ?
|
|
168
|
+
`).get(peerId);
|
|
169
|
+
if (!row) return { state: 'unknown', reason: 'peer_missing' };
|
|
170
|
+
const binding = db.prepare(`
|
|
171
|
+
SELECT transport, runtime_target FROM peer_bindings WHERE peer = ?
|
|
172
|
+
`).get(peerId) || null;
|
|
173
|
+
return observePeerEvidence(projectCtx, row, binding);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function providerOwnerEvidenceFromDb(db, peerId) {
|
|
177
|
+
const row = db.prepare(`
|
|
178
|
+
SELECT id, status, pid, pid_start_token, pid_command_hash
|
|
179
|
+
FROM peers WHERE id = ?
|
|
180
|
+
`).get(peerId);
|
|
181
|
+
return row
|
|
182
|
+
? resolvePeerEvidence({ peer: row, processes: [processEvidenceFromRow(row, 'provider-owner')] })
|
|
183
|
+
: { state: 'unknown', reason: 'peer_missing' };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function peerMutationSubject(db, peerId) {
|
|
187
|
+
const peer = db.prepare(`
|
|
188
|
+
SELECT id, status, pid, pid_start_token, pid_command_hash, last_seen_at
|
|
189
|
+
FROM peers WHERE id = ?
|
|
190
|
+
`).get(peerId) || null;
|
|
191
|
+
const binding = db.prepare(`
|
|
192
|
+
SELECT peer, transport, runtime_target, updated_at
|
|
193
|
+
FROM peer_bindings WHERE peer = ?
|
|
194
|
+
`).get(peerId) || null;
|
|
195
|
+
return { peer, binding };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function mutatePeerWithEvidence(db, projectCtx, peerId, mutate, options = {}) {
|
|
199
|
+
const acceptEvidence = typeof options.acceptEvidence === 'function'
|
|
200
|
+
? options.acceptEvidence
|
|
201
|
+
: (evidence) => evidence.state === 'dead';
|
|
202
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
203
|
+
const subject = peerMutationSubject(db, peerId);
|
|
204
|
+
if (!subject.peer) return { changed: false, evidence: { state: 'unknown', reason: 'peer_missing' } };
|
|
205
|
+
const evidence = observePeerEvidence(projectCtx, subject.peer, subject.binding);
|
|
206
|
+
if (!acceptEvidence(evidence, subject)) return { changed: false, evidence };
|
|
207
|
+
let subjectChanged = false;
|
|
208
|
+
let blocked = false;
|
|
209
|
+
const changed = tx(db, () => {
|
|
210
|
+
if (options.beforeMutate?.({ subject, evidence }) === false) {
|
|
211
|
+
blocked = true;
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
const current = peerMutationSubject(db, peerId);
|
|
215
|
+
if (JSON.stringify(current) !== JSON.stringify(subject)) {
|
|
216
|
+
subjectChanged = true;
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
mutate(current, evidence);
|
|
220
|
+
return true;
|
|
221
|
+
});
|
|
222
|
+
if (blocked) return { changed: false, evidence, blocked: true };
|
|
223
|
+
if (!subjectChanged) return { changed, evidence };
|
|
224
|
+
}
|
|
225
|
+
return { changed: false, evidence: { state: 'unknown', reason: 'subject_changed' } };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function mutateConfirmedDeadPeer(db, projectCtx, peerId, mutate, options = {}) {
|
|
229
|
+
return mutatePeerWithEvidence(db, projectCtx, peerId, mutate, options);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function observeClockSafetyOrThrow(db, options) {
|
|
233
|
+
try {
|
|
234
|
+
return observeClockSafety(db, options);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
throw clockSafetyUnavailable(err);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function observeClockSafetyInTransactionOrThrow(db, options) {
|
|
241
|
+
try {
|
|
242
|
+
return observeClockSafetyInTransaction(db, options);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
throw clockSafetyUnavailable(err);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function prepareLockClockObservation(db, subject, evidenceByOwner) {
|
|
249
|
+
return observeClockSafetyInTransactionOrThrow(db, {
|
|
250
|
+
operation: 'ownership',
|
|
251
|
+
candidates: clockCandidatesFromLocks(subject, evidenceByOwner),
|
|
252
|
+
nowSec: subject.observedAt
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function observeLockClockSafety(db, projectCtx, {
|
|
257
|
+
requested = null,
|
|
258
|
+
taskId = null,
|
|
259
|
+
owner = null,
|
|
260
|
+
observedAt = now()
|
|
261
|
+
} = {}) {
|
|
262
|
+
try {
|
|
263
|
+
const subject = captureLockAcquireSubject(db, {
|
|
264
|
+
taskId,
|
|
265
|
+
requested,
|
|
266
|
+
now: observedAt
|
|
267
|
+
});
|
|
268
|
+
const evidenceByOwner = observeLockOwnerEvidence(subject, (row, binding) =>
|
|
269
|
+
observePeerEvidence(projectCtx, row, binding));
|
|
270
|
+
const candidates = subject.locks
|
|
271
|
+
.filter((lock) => Number(lock.expires_at) <= observedAt && (!owner || lock.owner === owner))
|
|
272
|
+
.map((lock) => ({
|
|
273
|
+
boundary: Number(lock.expires_at),
|
|
274
|
+
evidence: evidenceByOwner.get(lock.owner)?.state || 'unknown',
|
|
275
|
+
owner: lock.owner,
|
|
276
|
+
resource: lock.resource
|
|
277
|
+
}));
|
|
278
|
+
return observeClockSafetyOrThrow(db, {
|
|
279
|
+
operation: 'ownership',
|
|
280
|
+
candidates,
|
|
281
|
+
nowSec: observedAt
|
|
282
|
+
});
|
|
283
|
+
} catch (err) {
|
|
284
|
+
throw clockSafetyUnavailable(err);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function observeTaskTakeoverClockSafety({ db, row, ownerRow, evidence, staleAfter }) {
|
|
289
|
+
const boundary = ownerRow
|
|
290
|
+
? Number(ownerRow.last_seen_at || 0) + Number(staleAfter)
|
|
291
|
+
: 0;
|
|
292
|
+
return observeClockSafetyOrThrow(db, {
|
|
293
|
+
operation: 'ownership',
|
|
294
|
+
candidates: row?.owner ? [{
|
|
295
|
+
boundary,
|
|
296
|
+
evidence: evidence?.state || 'unknown',
|
|
297
|
+
owner: row.owner
|
|
298
|
+
}] : [],
|
|
299
|
+
nowSec: now()
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
isProjectManagedTmuxSession, liveProcessIdentity, storedPeerIdentity,
|
|
305
|
+
processEvidenceFromRow, canonicalRoot, rootEvidence, tmuxTargetMissing,
|
|
306
|
+
inspectTmuxTarget, observePeerEvidence, peerEvidenceFromDb,
|
|
307
|
+
providerOwnerEvidenceFromDb, peerMutationSubject, mutatePeerWithEvidence,
|
|
308
|
+
mutateConfirmedDeadPeer, observeClockSafetyOrThrow,
|
|
309
|
+
observeClockSafetyInTransactionOrThrow, prepareLockClockObservation,
|
|
310
|
+
observeLockClockSafety, observeTaskTakeoverClockSafety
|
|
311
|
+
};
|
|
312
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { compareProcessIdentity } from '../../process/identity.mjs';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_ACTIVE_PEER_TTL = 600;
|
|
4
|
+
|
|
5
|
+
export function classifyPeerActivity(peer = {}, options = {}) {
|
|
6
|
+
const activePeerTtl = Number(options.activePeerTtl ?? DEFAULT_ACTIVE_PEER_TTL);
|
|
7
|
+
const graceActive = Boolean(options.graceActive);
|
|
8
|
+
const evidenceState = peer.evidence_state || peer.evidence?.state || 'unknown';
|
|
9
|
+
const age = Number(peer.age_sec);
|
|
10
|
+
const active = evidenceState === 'live' ||
|
|
11
|
+
(evidenceState !== 'dead' && (graceActive || (Number.isFinite(age) && age <= activePeerTtl)));
|
|
12
|
+
return { active, stale: !active };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function peerEvidenceAllowsReap(evidence = {}, options = {}) {
|
|
16
|
+
if (evidence.state === 'dead') return true;
|
|
17
|
+
if (evidence.state !== 'unknown') return false;
|
|
18
|
+
const nowSec = Number(options.nowSec);
|
|
19
|
+
const lastSeenAt = Number(options.lastSeenAt);
|
|
20
|
+
const staleAfterSec = Number(options.staleAfterSec);
|
|
21
|
+
const graceUntil = Number(options.graceUntil ?? 0);
|
|
22
|
+
if (![nowSec, lastSeenAt, staleAfterSec, graceUntil].every((value) =>
|
|
23
|
+
Number.isSafeInteger(value) && value >= 0)) return false;
|
|
24
|
+
return nowSec - lastSeenAt >= staleAfterSec && nowSec >= graceUntil;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function processState(evidence) {
|
|
28
|
+
if (!evidence) return 'unknown';
|
|
29
|
+
if (compareProcessIdentity(evidence.storedIdentity, evidence.storedIdentity) !== 'live') {
|
|
30
|
+
// CS-06: no usable stored fingerprint (legacy row without pid_start_token /
|
|
31
|
+
// pid_command_hash, or a missing pid). We cannot claim ownership — but a
|
|
32
|
+
// process that is demonstrably gone is still a dead process, so such peers
|
|
33
|
+
// are reaped once their process exits. A live-but-unverifiable process
|
|
34
|
+
// stays 'unknown' (protected).
|
|
35
|
+
return evidence.current?.state === 'dead' ? 'dead' : 'unknown';
|
|
36
|
+
}
|
|
37
|
+
if (evidence.current?.state === 'unknown') return 'unknown';
|
|
38
|
+
if (evidence.current?.state === 'dead') return 'dead';
|
|
39
|
+
if (evidence.current?.state !== 'live') return 'unknown';
|
|
40
|
+
return compareProcessIdentity(evidence.storedIdentity, evidence.current.identity);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function aggregateProcesses(processes) {
|
|
44
|
+
if (!Array.isArray(processes) || processes.length === 0) {
|
|
45
|
+
return { state: 'unknown', reason: 'process_evidence_missing' };
|
|
46
|
+
}
|
|
47
|
+
const states = processes.map(processState);
|
|
48
|
+
if (states.includes('live')) {
|
|
49
|
+
return { state: 'live', reason: 'process_identity_match' };
|
|
50
|
+
}
|
|
51
|
+
if (states.every((state) => state === 'dead')) {
|
|
52
|
+
const missing = processes.every((item) => item?.current?.state === 'dead');
|
|
53
|
+
return {
|
|
54
|
+
state: 'dead',
|
|
55
|
+
reason: missing ? 'process_missing' : 'process_identity_mismatch'
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return { state: 'unknown', reason: 'process_identity_incomplete' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function tmuxProcessState(tmux) {
|
|
62
|
+
return processState(tmux?.process);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function tmuxConfirmedDead(tmux) {
|
|
66
|
+
const exactSession = tmux?.session?.state === 'live' &&
|
|
67
|
+
tmux?.session?.expected === tmux?.session?.actual;
|
|
68
|
+
return tmux?.session?.state === 'dead' ||
|
|
69
|
+
(exactSession && tmux?.pane?.state === 'dead');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function tmuxVerifiedLive(tmux) {
|
|
73
|
+
return tmux?.session?.state === 'live' &&
|
|
74
|
+
tmux?.session?.expected === tmux?.session?.actual &&
|
|
75
|
+
tmux?.pane?.state === 'live' &&
|
|
76
|
+
tmux?.pane?.expected === tmux?.pane?.actual &&
|
|
77
|
+
tmux?.root?.state === 'match' &&
|
|
78
|
+
tmuxProcessState(tmux) === 'live';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function resolvePeerEvidence(input = {}) {
|
|
82
|
+
const ordinaryProcess = aggregateProcesses(input.processes);
|
|
83
|
+
if (ordinaryProcess.state === 'live') return ordinaryProcess;
|
|
84
|
+
|
|
85
|
+
const tmux = input.tmux?.managed ? input.tmux : null;
|
|
86
|
+
if (ordinaryProcess.state === 'unknown' && tmux && tmuxVerifiedLive(tmux)) {
|
|
87
|
+
return { state: 'live', reason: 'tmux_identity_match' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (input.peer?.status === 'exited') {
|
|
91
|
+
return { state: 'dead', reason: 'explicit_exited' };
|
|
92
|
+
}
|
|
93
|
+
if (ordinaryProcess.state === 'dead' || !tmux) return ordinaryProcess;
|
|
94
|
+
|
|
95
|
+
if (tmux.root?.state === 'mismatch') {
|
|
96
|
+
return { state: 'unknown', reason: 'tmux_root_mismatch' };
|
|
97
|
+
}
|
|
98
|
+
const paneProcess = tmuxProcessState(tmux);
|
|
99
|
+
if (tmuxConfirmedDead(tmux) && paneProcess === 'dead') {
|
|
100
|
+
return { state: 'dead', reason: 'tmux_and_process_dead' };
|
|
101
|
+
}
|
|
102
|
+
return { state: 'unknown', reason: 'tmux_evidence_incomplete' };
|
|
103
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
function requireObservedAt(value) {
|
|
2
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
3
|
+
throw new TypeError('observedAt must be a non-negative safe integer');
|
|
4
|
+
}
|
|
5
|
+
return value;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function refreshHookOwnerIdentity(db, {
|
|
9
|
+
peerId,
|
|
10
|
+
status = null,
|
|
11
|
+
observedAt,
|
|
12
|
+
processIdentity = null
|
|
13
|
+
} = {}) {
|
|
14
|
+
if (!db || typeof db.prepare !== 'function') throw new TypeError('db is required');
|
|
15
|
+
if (typeof peerId !== 'string' || !peerId) throw new TypeError('peerId is required');
|
|
16
|
+
const timestamp = requireObservedAt(observedAt);
|
|
17
|
+
if (!processIdentity) {
|
|
18
|
+
return db.prepare(`
|
|
19
|
+
UPDATE peers
|
|
20
|
+
SET last_seen_at = ?, status = COALESCE(?, status), pid = NULL,
|
|
21
|
+
pid_start_token = NULL, pid_command_hash = NULL
|
|
22
|
+
WHERE id = ?
|
|
23
|
+
`).run(timestamp, status, peerId).changes;
|
|
24
|
+
}
|
|
25
|
+
return db.prepare(`
|
|
26
|
+
UPDATE peers
|
|
27
|
+
SET last_seen_at = ?, status = COALESCE(?, status), pid = ?,
|
|
28
|
+
pid_start_token = ?, pid_command_hash = ?
|
|
29
|
+
WHERE id = ?
|
|
30
|
+
`).run(timestamp, status, processIdentity.pid, processIdentity.startToken,
|
|
31
|
+
processIdentity.commandHash, peerId).changes;
|
|
32
|
+
}
|
|
@@ -1,9 +1,32 @@
|
|
|
1
1
|
const DEFAULT_ACTIVE_PEER_TTL = 600;
|
|
2
2
|
|
|
3
|
+
import { clockGraceSuppressed } from '../../shared/clock-grace.mjs';
|
|
4
|
+
import { inspectProcessIdentity } from '../../process/identity.mjs';
|
|
5
|
+
import { classifyPeerActivity, resolvePeerEvidence } from './evidence.mjs';
|
|
6
|
+
|
|
3
7
|
function nowSec() {
|
|
4
8
|
return Math.floor(Date.now() / 1000);
|
|
5
9
|
}
|
|
6
10
|
|
|
11
|
+
function rowEvidence(row) {
|
|
12
|
+
if (row?.evidence_state) {
|
|
13
|
+
return { state: row.evidence_state, reason: row.evidence_reason || 'observed' };
|
|
14
|
+
}
|
|
15
|
+
const storedIdentity = row?.pid ? {
|
|
16
|
+
pid: Number(row.pid),
|
|
17
|
+
startToken: row.pid_start_token,
|
|
18
|
+
commandHash: row.pid_command_hash
|
|
19
|
+
} : null;
|
|
20
|
+
return resolvePeerEvidence({
|
|
21
|
+
peer: row,
|
|
22
|
+
processes: row?.pid ? [{
|
|
23
|
+
name: 'peer',
|
|
24
|
+
storedIdentity,
|
|
25
|
+
current: inspectProcessIdentity(row.pid)
|
|
26
|
+
}] : []
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
7
30
|
export function taskRelatedLocks(task, locks) {
|
|
8
31
|
const taskId = Number(task?.id || 0);
|
|
9
32
|
const owner = task?.owner || '';
|
|
@@ -13,7 +36,7 @@ export function taskRelatedLocks(task, locks) {
|
|
|
13
36
|
});
|
|
14
37
|
}
|
|
15
38
|
|
|
16
|
-
export function taskOwnerLiveness(task, peers, locks, t = nowSec(), activePeerTtl = DEFAULT_ACTIVE_PEER_TTL) {
|
|
39
|
+
export function taskOwnerLiveness(task, peers, locks, t = nowSec(), activePeerTtl = DEFAULT_ACTIVE_PEER_TTL, graceUntil = 0) {
|
|
17
40
|
const owner = task?.owner || null;
|
|
18
41
|
const relatedLocks = taskRelatedLocks(task, locks);
|
|
19
42
|
if (!owner) {
|
|
@@ -22,6 +45,8 @@ export function taskOwnerLiveness(task, peers, locks, t = nowSec(), activePeerTt
|
|
|
22
45
|
owner_active: null,
|
|
23
46
|
owner_stale: false,
|
|
24
47
|
owner_age_sec: null,
|
|
48
|
+
owner_evidence_state: 'unknown',
|
|
49
|
+
owner_evidence_reason: 'owner_missing',
|
|
25
50
|
related_lock_count: relatedLocks.length,
|
|
26
51
|
takeover_ready: false
|
|
27
52
|
};
|
|
@@ -30,7 +55,18 @@ export function taskOwnerLiveness(task, peers, locks, t = nowSec(), activePeerTt
|
|
|
30
55
|
const ownerAge = ownerRow
|
|
31
56
|
? Number(ownerRow.age_sec ?? (t - Number(ownerRow.last_seen_at || 0)))
|
|
32
57
|
: null;
|
|
33
|
-
const
|
|
58
|
+
const evidence = ownerRow ? rowEvidence(ownerRow) : { state: 'unknown', reason: 'peer_missing' };
|
|
59
|
+
// hb-05: after a wall-clock jump (sleep/NTP), age is untrustworthy for the
|
|
60
|
+
// grace window; treat every owner as active so a live owner's task is never
|
|
61
|
+
// spuriously marked stale/takeover-able.
|
|
62
|
+
const graceActive = clockGraceSuppressed(t, graceUntil);
|
|
63
|
+
const activity = classifyPeerActivity({
|
|
64
|
+
...ownerRow,
|
|
65
|
+
age_sec: ownerAge,
|
|
66
|
+
evidence_state: evidence.state,
|
|
67
|
+
evidence_reason: evidence.reason
|
|
68
|
+
}, { activePeerTtl, graceActive });
|
|
69
|
+
const ownerActive = Boolean(ownerRow && activity.active);
|
|
34
70
|
const ownerStale = !ownerActive;
|
|
35
71
|
const takeoverStatus = ['claimed', 'running', 'review', 'blocked'].includes(task.status);
|
|
36
72
|
return {
|
|
@@ -38,15 +74,17 @@ export function taskOwnerLiveness(task, peers, locks, t = nowSec(), activePeerTt
|
|
|
38
74
|
owner_active: ownerActive,
|
|
39
75
|
owner_stale: ownerStale,
|
|
40
76
|
owner_age_sec: Number.isFinite(ownerAge) ? ownerAge : null,
|
|
77
|
+
owner_evidence_state: evidence.state,
|
|
78
|
+
owner_evidence_reason: evidence.reason,
|
|
41
79
|
related_lock_count: relatedLocks.length,
|
|
42
80
|
takeover_ready: Boolean(takeoverStatus && ownerStale && relatedLocks.length === 0)
|
|
43
81
|
};
|
|
44
82
|
}
|
|
45
83
|
|
|
46
|
-
export function annotateTasksWithLiveness(tasks, peers, locks, t = nowSec(), activePeerTtl = DEFAULT_ACTIVE_PEER_TTL) {
|
|
84
|
+
export function annotateTasksWithLiveness(tasks, peers, locks, t = nowSec(), activePeerTtl = DEFAULT_ACTIVE_PEER_TTL, graceUntil = 0) {
|
|
47
85
|
return (tasks || []).map((task) => ({
|
|
48
86
|
...task,
|
|
49
|
-
...taskOwnerLiveness(task, peers, locks, t, activePeerTtl)
|
|
87
|
+
...taskOwnerLiveness(task, peers, locks, t, activePeerTtl, graceUntil)
|
|
50
88
|
}));
|
|
51
89
|
}
|
|
52
90
|
|
|
@@ -75,6 +113,8 @@ export function summarizeTask(task) {
|
|
|
75
113
|
owner_active: task.owner_active ?? null,
|
|
76
114
|
owner_stale: Boolean(task.owner_stale),
|
|
77
115
|
owner_age_sec: task.owner_age_sec ?? null,
|
|
116
|
+
owner_evidence_state: task.owner_evidence_state || 'unknown',
|
|
117
|
+
owner_evidence_reason: task.owner_evidence_reason || null,
|
|
78
118
|
related_lock_count: Number(task.related_lock_count || 0),
|
|
79
119
|
takeover_ready: Boolean(task.takeover_ready)
|
|
80
120
|
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Peer upsert/touch helpers extracted from bin/hcc.mjs.
|
|
2
|
+
// Factory pattern: callers inject the functions that remain in bin/hcc.mjs.
|
|
3
|
+
|
|
4
|
+
export function createPeerHelpers({
|
|
5
|
+
now,
|
|
6
|
+
addEvent,
|
|
7
|
+
liveProcessIdentity,
|
|
8
|
+
detectBranch,
|
|
9
|
+
registerProjectActivity,
|
|
10
|
+
upsertCanonicalPeerBinding,
|
|
11
|
+
autoPeerKind,
|
|
12
|
+
autoPeerSessionId,
|
|
13
|
+
autoPeerResumeId,
|
|
14
|
+
autoPeerDefaults,
|
|
15
|
+
autoPeerBasis,
|
|
16
|
+
providerSessionParts
|
|
17
|
+
}) {
|
|
18
|
+
function upsertPeer(db, peer) {
|
|
19
|
+
const t = now();
|
|
20
|
+
const identity = Object.hasOwn(peer, 'processIdentity')
|
|
21
|
+
? peer.processIdentity
|
|
22
|
+
: liveProcessIdentity(peer.pid);
|
|
23
|
+
db.prepare(`
|
|
24
|
+
INSERT INTO peers(id, kind, role, worktree, branch, pid, pid_start_token, pid_command_hash, status, capabilities, created_at, last_seen_at)
|
|
25
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
26
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
27
|
+
kind = excluded.kind,
|
|
28
|
+
role = excluded.role,
|
|
29
|
+
worktree = excluded.worktree,
|
|
30
|
+
branch = excluded.branch,
|
|
31
|
+
pid = excluded.pid,
|
|
32
|
+
pid_start_token = excluded.pid_start_token,
|
|
33
|
+
pid_command_hash = excluded.pid_command_hash,
|
|
34
|
+
-- hb-08: an incidental upsert with the default 'idle' status must not
|
|
35
|
+
-- resurrect a peer that was explicitly marked exited/detached.
|
|
36
|
+
status = CASE WHEN excluded.status = 'idle' AND peers.status IN ('exited', 'detached')
|
|
37
|
+
THEN peers.status ELSE excluded.status END,
|
|
38
|
+
capabilities = excluded.capabilities,
|
|
39
|
+
last_seen_at = excluded.last_seen_at
|
|
40
|
+
`).run(
|
|
41
|
+
peer.id,
|
|
42
|
+
peer.kind || 'other',
|
|
43
|
+
peer.role || '',
|
|
44
|
+
peer.worktree || '',
|
|
45
|
+
peer.branch || '',
|
|
46
|
+
peer.pid || null,
|
|
47
|
+
identity?.startToken || null,
|
|
48
|
+
identity?.commandHash || null,
|
|
49
|
+
peer.status || 'idle',
|
|
50
|
+
peer.capabilities || '',
|
|
51
|
+
t,
|
|
52
|
+
t
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function touchPeer(db, id, status = null) {
|
|
57
|
+
if (!id) return;
|
|
58
|
+
const existing = db.prepare('SELECT id FROM peers WHERE id = ?').get(id);
|
|
59
|
+
if (!existing) {
|
|
60
|
+
upsertPeer(db, {
|
|
61
|
+
id,
|
|
62
|
+
kind: 'other',
|
|
63
|
+
role: 'auto',
|
|
64
|
+
worktree: process.cwd(),
|
|
65
|
+
branch: detectBranch(process.cwd()),
|
|
66
|
+
pid: process.ppid,
|
|
67
|
+
status: status || 'idle',
|
|
68
|
+
capabilities: ''
|
|
69
|
+
});
|
|
70
|
+
} else {
|
|
71
|
+
db.prepare(`
|
|
72
|
+
UPDATE peers
|
|
73
|
+
SET last_seen_at = ?, status = COALESCE(?, status)
|
|
74
|
+
WHERE id = ?
|
|
75
|
+
`).run(now(), status, id);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function touchCurrentPeer(db, ctx, resolved, status = null, kindHint = 'shell') {
|
|
80
|
+
registerProjectActivity(ctx);
|
|
81
|
+
const identity = typeof resolved === 'string'
|
|
82
|
+
? { id: resolved, auto: false }
|
|
83
|
+
: resolved;
|
|
84
|
+
if (!identity || !identity.id) return;
|
|
85
|
+
if (!identity.auto) {
|
|
86
|
+
touchPeer(db, identity.id, status);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const existing = db.prepare('SELECT id FROM peers WHERE id = ?').get(identity.id);
|
|
91
|
+
if (existing) {
|
|
92
|
+
touchPeer(db, identity.id, status);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const kind = autoPeerKind(kindHint);
|
|
97
|
+
const sessionId = autoPeerSessionId(kind);
|
|
98
|
+
const resumeId = autoPeerResumeId(kind);
|
|
99
|
+
upsertPeer(db, {
|
|
100
|
+
id: identity.id,
|
|
101
|
+
...autoPeerDefaults(ctx, kindHint, status || 'idle')
|
|
102
|
+
});
|
|
103
|
+
upsertCanonicalPeerBinding(db, {
|
|
104
|
+
peer: identity.id,
|
|
105
|
+
provider: kind,
|
|
106
|
+
...providerSessionParts(resumeId || sessionId),
|
|
107
|
+
resume_mode: resumeId ? 'resume' : (sessionId ? 'detected' : 'auto'),
|
|
108
|
+
resume_arg: resumeId || null,
|
|
109
|
+
command: null,
|
|
110
|
+
transport: process.env.TMUX_PANE ? 'auto-tmux' : 'auto-shell',
|
|
111
|
+
runtime_session_id: identity.id
|
|
112
|
+
}, true);
|
|
113
|
+
addEvent(db, 'peer.auto_joined', identity.id, null, {
|
|
114
|
+
root: ctx.root,
|
|
115
|
+
basis: autoPeerBasis(kind),
|
|
116
|
+
provider_session: resumeId || sessionId || null
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { upsertPeer, touchPeer, touchCurrentPeer };
|
|
121
|
+
}
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { shortHash } from './format.mjs';
|
|
2
2
|
|
|
3
|
+
// sess-04: derive the peer id from a sha1 hash of the FULL provider session
|
|
4
|
+
// id/name, not the first 8 raw chars — name-based ids that share a prefix
|
|
5
|
+
// (e.g. codex `feature-login` vs `feature-logout`) used to collide into the
|
|
6
|
+
// same peer id. The shim script derives the identical value (see
|
|
7
|
+
// lib/integrations/shims/script.mjs peer_hash). NOTE: this changes existing
|
|
8
|
+
// peer ids for name-based resumes (pre-1.0 intentional identity change).
|
|
3
9
|
export function providerSessionPeerId(kind, providerId) {
|
|
4
|
-
return `${kind}-${
|
|
10
|
+
return `${kind}-${shortHash(String(providerId || ''))}`;
|
|
5
11
|
}
|
|
6
12
|
|
|
7
13
|
export function providerSessionParts(value) {
|