@logicseek/hello-cc 0.1.9 → 1.0.0
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 +59 -0
- package/README.md +18 -3
- package/README.zh-CN.md +17 -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/current-state-review-2026-08.md +341 -0
- package/docs/defect-review-2026-07.md +382 -0
- package/docs/guide.md +39 -3
- package/docs/guide.zh-CN.md +34 -2
- package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +294 -0
- package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +400 -0
- package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +284 -0
- package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +303 -0
- package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +71 -0
- package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +117 -0
- package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +120 -0
- package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +117 -0
- package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +104 -0
- package/lib/cli/app.mjs +642 -0
- package/lib/cli/commands/coordination.mjs +247 -0
- package/lib/cli/commands/doctor.mjs +81 -0
- package/lib/cli/commands/gc.mjs +852 -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 +61 -0
- package/lib/cli/commands/msg.mjs +152 -0
- package/lib/cli/commands/peer.mjs +233 -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 +192 -0
- package/lib/process/pty-lifecycle.mjs +149 -0
- package/lib/runtime/buffer-directory-lease.mjs +47 -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 +60 -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 +370 -0
- package/lib/shared/json-file.mjs +6 -2
- package/lib/shared/redact.mjs +60 -0
- package/lib/shared/socket-lock-worker.mjs +201 -0
- package/lib/terminal/tmux.mjs +78 -4
- 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 +178 -0
- package/lib/web/pty-sessions.mjs +203 -0
- package/lib/web/runtime-main.mjs +714 -0
- package/lib/web/runtime.mjs +91 -36
- package/lib/web/session-serialize.mjs +0 -0
- package/lib/web/startup.mjs +333 -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 +5 -2
- package/scripts/check-free-identifiers.mjs +290 -0
- package/scripts/regression.mjs +5397 -263
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { CliError } from '../../shared/errors.mjs';
|
|
2
|
+
|
|
3
|
+
function requireSafeTimestamp(value, name) {
|
|
4
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
5
|
+
throw new TypeError(`${name} must be a non-negative safe integer`);
|
|
6
|
+
}
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function leaseDeadline(nowSec, ttl, { option = '--ttl' } = {}) {
|
|
11
|
+
const observedAt = requireSafeTimestamp(nowSec, 'nowSec');
|
|
12
|
+
const duration = requirePositiveSafeInteger(ttl, 'ttl');
|
|
13
|
+
const deadline = observedAt + duration;
|
|
14
|
+
if (!Number.isSafeInteger(deadline)) {
|
|
15
|
+
throw new CliError('BAD_ARGS', `${option} produces an unsafe lease deadline`);
|
|
16
|
+
}
|
|
17
|
+
return deadline;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function requirePositiveSafeInteger(value, name) {
|
|
21
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
22
|
+
throw new TypeError(`${name} must be a positive safe integer`);
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function persistedLockTtls(db, owner, observedAt, includeExpired) {
|
|
28
|
+
const expiryPredicate = includeExpired ? '' : ' AND expires_at > ?';
|
|
29
|
+
const args = includeExpired ? [owner] : [owner, observedAt];
|
|
30
|
+
return db.prepare(`
|
|
31
|
+
SELECT resource, ttl_sec
|
|
32
|
+
FROM locks
|
|
33
|
+
WHERE owner = ?${expiryPredicate}
|
|
34
|
+
`).all(...args);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function requireRenewablePersistedTtls(db, owner, observedAt, includeExpired, { cap = null } = {}) {
|
|
38
|
+
for (const row of persistedLockTtls(db, owner, observedAt, includeExpired)) {
|
|
39
|
+
const ttl = Number(row.ttl_sec);
|
|
40
|
+
try {
|
|
41
|
+
requirePositiveSafeInteger(ttl, 'persisted lock TTL');
|
|
42
|
+
leaseDeadline(observedAt, cap === null ? ttl : Math.min(ttl, cap), {
|
|
43
|
+
option: 'persisted lock TTL'
|
|
44
|
+
});
|
|
45
|
+
} catch {
|
|
46
|
+
throw new CliError('BAD_STATE', `Lock ${row.resource} has an unsafe persisted TTL`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function renewOwnedLocks(db, {
|
|
52
|
+
owner,
|
|
53
|
+
nowSec,
|
|
54
|
+
ttlOverride = null,
|
|
55
|
+
ttlCap = null,
|
|
56
|
+
includeExpired = false
|
|
57
|
+
} = {}) {
|
|
58
|
+
if (!db || typeof db.prepare !== 'function') throw new TypeError('db is required');
|
|
59
|
+
if (typeof owner !== 'string' || !owner) throw new TypeError('owner is required');
|
|
60
|
+
const observedAt = requireSafeTimestamp(nowSec, 'nowSec');
|
|
61
|
+
const expiryPredicate = includeExpired ? '' : ' AND expires_at > ?';
|
|
62
|
+
|
|
63
|
+
if (ttlOverride !== null) {
|
|
64
|
+
const ttl = requirePositiveSafeInteger(ttlOverride, 'ttlOverride');
|
|
65
|
+
const target = leaseDeadline(observedAt, ttl);
|
|
66
|
+
const args = includeExpired
|
|
67
|
+
? [target, ttl, owner]
|
|
68
|
+
: [target, ttl, owner, observedAt];
|
|
69
|
+
return db.prepare(`
|
|
70
|
+
UPDATE locks
|
|
71
|
+
SET expires_at = MAX(expires_at, ?), ttl_sec = ?
|
|
72
|
+
WHERE owner = ?${expiryPredicate}
|
|
73
|
+
`).run(...args).changes;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (ttlCap !== null) {
|
|
77
|
+
const cap = requirePositiveSafeInteger(ttlCap, 'ttlCap');
|
|
78
|
+
leaseDeadline(observedAt, cap, { option: 'hook TTL cap' });
|
|
79
|
+
requireRenewablePersistedTtls(db, owner, observedAt, includeExpired, { cap });
|
|
80
|
+
const args = includeExpired
|
|
81
|
+
? [observedAt, cap, owner]
|
|
82
|
+
: [observedAt, cap, owner, observedAt];
|
|
83
|
+
return db.prepare(`
|
|
84
|
+
UPDATE locks
|
|
85
|
+
SET expires_at = MAX(expires_at, ? + MIN(ttl_sec, ?))
|
|
86
|
+
WHERE owner = ?${expiryPredicate}
|
|
87
|
+
`).run(...args).changes;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
requireRenewablePersistedTtls(db, owner, observedAt, includeExpired);
|
|
91
|
+
const maxTtl = Number.MAX_SAFE_INTEGER - observedAt;
|
|
92
|
+
const args = includeExpired
|
|
93
|
+
? [observedAt, owner, maxTtl]
|
|
94
|
+
: [observedAt, owner, observedAt, maxTtl];
|
|
95
|
+
return db.prepare(`
|
|
96
|
+
UPDATE locks
|
|
97
|
+
SET expires_at = MAX(expires_at, ? + ttl_sec)
|
|
98
|
+
WHERE owner = ?${expiryPredicate}
|
|
99
|
+
AND typeof(ttl_sec) = 'integer'
|
|
100
|
+
AND ttl_sec BETWEEN 1 AND ?
|
|
101
|
+
`).run(...args).changes;
|
|
102
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { readClockGraceUntil } from '../../shared/clock-grace.mjs';
|
|
2
|
+
import { locksConflict } from './locks.mjs';
|
|
3
|
+
|
|
4
|
+
export function captureLockAcquireSubject(db, options = {}) {
|
|
5
|
+
const taskId = options.taskId || null;
|
|
6
|
+
const requested = options.requested || null;
|
|
7
|
+
const observedAt = Number(options.now);
|
|
8
|
+
const task = taskId ? db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) || null : null;
|
|
9
|
+
const lockRows = requested
|
|
10
|
+
? db.prepare(`
|
|
11
|
+
SELECT * FROM locks
|
|
12
|
+
WHERE base_resource = ?
|
|
13
|
+
OR (base_resource IS NULL AND resource = ?)
|
|
14
|
+
ORDER BY resource ASC
|
|
15
|
+
`).all(requested.base_resource, requested.base_resource)
|
|
16
|
+
: db.prepare('SELECT * FROM locks ORDER BY resource ASC').all();
|
|
17
|
+
const locks = lockRows
|
|
18
|
+
.filter((lock) => !requested || locksConflict(lock, requested));
|
|
19
|
+
const expiredLocks = locks.filter((lock) => Number(lock.expires_at) <= observedAt);
|
|
20
|
+
const owners = [];
|
|
21
|
+
for (const owner of [...new Set(expiredLocks.map((lock) => lock.owner).filter(Boolean))].sort()) {
|
|
22
|
+
const peer = db.prepare(`
|
|
23
|
+
SELECT id, status, pid, pid_start_token, pid_command_hash, last_seen_at
|
|
24
|
+
FROM peers WHERE id = ?
|
|
25
|
+
`).get(owner) || null;
|
|
26
|
+
const binding = db.prepare(`
|
|
27
|
+
SELECT peer, transport, runtime_target, updated_at
|
|
28
|
+
FROM peer_bindings WHERE peer = ?
|
|
29
|
+
`).get(owner) || null;
|
|
30
|
+
owners.push({ owner, peer, binding });
|
|
31
|
+
}
|
|
32
|
+
return { task, locks, owners, graceUntil: readClockGraceUntil(db), observedAt };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function sameLockAcquireSubject(left, right) {
|
|
36
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function observeLockOwnerEvidence(subject, observePeer) {
|
|
40
|
+
return new Map((subject.owners || []).map(({ owner, peer, binding }) => [
|
|
41
|
+
owner,
|
|
42
|
+
peer ? observePeer(peer, binding) : { state: 'unknown', reason: 'peer_missing' }
|
|
43
|
+
]));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function captureLockRenewalSubject(db, { owner, now }) {
|
|
47
|
+
const peer = db.prepare(`
|
|
48
|
+
SELECT id, status, pid, pid_start_token, pid_command_hash, last_seen_at
|
|
49
|
+
FROM peers WHERE id = ?
|
|
50
|
+
`).get(owner) || null;
|
|
51
|
+
const binding = db.prepare(`
|
|
52
|
+
SELECT peer, transport, runtime_target, updated_at
|
|
53
|
+
FROM peer_bindings WHERE peer = ?
|
|
54
|
+
`).get(owner) || null;
|
|
55
|
+
const locks = db.prepare(`
|
|
56
|
+
SELECT * FROM locks WHERE owner = ? ORDER BY resource ASC
|
|
57
|
+
`).all(owner);
|
|
58
|
+
return {
|
|
59
|
+
owner,
|
|
60
|
+
peer,
|
|
61
|
+
binding,
|
|
62
|
+
locks,
|
|
63
|
+
graceUntil: readClockGraceUntil(db),
|
|
64
|
+
observedAt: Number(now)
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function clockCandidatesFromLocks(subject, evidenceByOwner, owner = null) {
|
|
69
|
+
return (subject.locks || [])
|
|
70
|
+
.filter((lock) => Number(lock.expires_at) <= subject.observedAt && (!owner || lock.owner === owner))
|
|
71
|
+
.map((lock) => ({
|
|
72
|
+
boundary: Number(lock.expires_at),
|
|
73
|
+
evidence: evidenceByOwner.get(lock.owner)?.state || 'unknown',
|
|
74
|
+
owner: lock.owner,
|
|
75
|
+
resource: lock.resource
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { tx } from '../../db/schema.mjs';
|
|
2
|
+
import { CliError } from '../../shared/errors.mjs';
|
|
3
|
+
|
|
4
|
+
export function runOptimisticEvidenceMutation(db, options = {}) {
|
|
5
|
+
const attempts = Number(options.attempts || 2);
|
|
6
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
7
|
+
const subject = options.capture(db);
|
|
8
|
+
const evidence = options.observe(subject);
|
|
9
|
+
let subjectChanged = false;
|
|
10
|
+
const result = tx(db, () => {
|
|
11
|
+
const current = options.capture(db);
|
|
12
|
+
if (!options.same(subject, current)) {
|
|
13
|
+
subjectChanged = true;
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const prepared = typeof options.beforeMutate === 'function'
|
|
17
|
+
? options.beforeMutate(current, evidence)
|
|
18
|
+
: undefined;
|
|
19
|
+
return options.mutate(current, evidence, prepared);
|
|
20
|
+
});
|
|
21
|
+
if (!subjectChanged) return result;
|
|
22
|
+
}
|
|
23
|
+
throw new CliError('SUBJECT_CHANGED', options.changedMessage || 'Coordination subject changed while evidence was being observed; retry', {
|
|
24
|
+
retryable: true
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { tx } from '../../db/schema.mjs';
|
|
2
2
|
import { CliError } from '../../shared/errors.mjs';
|
|
3
|
+
import { clockGraceSuppressed, readClockGraceUntil } from '../../shared/clock-grace.mjs';
|
|
4
|
+
import { inspectProcessIdentity } from '../../process/identity.mjs';
|
|
5
|
+
import { peerEvidenceAllowsReap, resolvePeerEvidence } from '../peers/evidence.mjs';
|
|
3
6
|
|
|
4
7
|
const DEFAULT_ACTIVE_PEER_TTL = 600;
|
|
8
|
+
const UNKNOWN_EVIDENCE_GRACE_SEC = 120;
|
|
5
9
|
|
|
6
10
|
function defaultNow() {
|
|
7
11
|
return Math.floor(Date.now() / 1000);
|
|
@@ -13,10 +17,28 @@ function noopMessage() {
|
|
|
13
17
|
return null;
|
|
14
18
|
}
|
|
15
19
|
|
|
20
|
+
function processEvidenceForPeer(row) {
|
|
21
|
+
return resolvePeerEvidence({
|
|
22
|
+
peer: row,
|
|
23
|
+
processes: row?.pid ? [{
|
|
24
|
+
name: 'peer',
|
|
25
|
+
storedIdentity: {
|
|
26
|
+
pid: Number(row.pid),
|
|
27
|
+
startToken: row.pid_start_token,
|
|
28
|
+
commandHash: row.pid_command_hash
|
|
29
|
+
},
|
|
30
|
+
current: inspectProcessIdentity(row.pid)
|
|
31
|
+
}] : []
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
16
35
|
export function createTaskStore(deps = {}) {
|
|
17
36
|
const now = typeof deps.now === 'function' ? deps.now : defaultNow;
|
|
18
37
|
const addEvent = typeof deps.addEvent === 'function' ? deps.addEvent : noopEvent;
|
|
19
38
|
const sendMessage = typeof deps.sendMessage === 'function' ? deps.sendMessage : noopMessage;
|
|
39
|
+
const observeClockSafety = typeof deps.observeClockSafety === 'function'
|
|
40
|
+
? deps.observeClockSafety
|
|
41
|
+
: null;
|
|
20
42
|
const activePeerTtl = Number(deps.activePeerTtl || DEFAULT_ACTIVE_PEER_TTL);
|
|
21
43
|
|
|
22
44
|
function taskOwnedError(peer, row, action) {
|
|
@@ -29,6 +51,23 @@ export function createTaskStore(deps = {}) {
|
|
|
29
51
|
});
|
|
30
52
|
}
|
|
31
53
|
|
|
54
|
+
function takeoverSubject(db, id) {
|
|
55
|
+
const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) || null;
|
|
56
|
+
const ownerRow = row?.owner ? db.prepare(`
|
|
57
|
+
SELECT id, status, pid, pid_start_token, pid_command_hash, last_seen_at
|
|
58
|
+
FROM peers WHERE id = ?
|
|
59
|
+
`).get(row.owner) || null : null;
|
|
60
|
+
const binding = row?.owner ? db.prepare(`
|
|
61
|
+
SELECT peer, transport, runtime_target, updated_at
|
|
62
|
+
FROM peer_bindings WHERE peer = ?
|
|
63
|
+
`).get(row.owner) || null : null;
|
|
64
|
+
return { row, ownerRow, binding, graceUntil: readClockGraceUntil(db) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sameTakeoverSubject(left, right) {
|
|
68
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
69
|
+
}
|
|
70
|
+
|
|
32
71
|
function claimTaskRowsForPeer(db, peer, ids, { force = false, source = null } = {}) {
|
|
33
72
|
return tx(db, () => {
|
|
34
73
|
const tasks = [];
|
|
@@ -61,7 +100,13 @@ export function createTaskStore(deps = {}) {
|
|
|
61
100
|
});
|
|
62
101
|
}
|
|
63
102
|
|
|
64
|
-
function takeoverPolicyDetails(db, row, peer, {
|
|
103
|
+
function takeoverPolicyDetails(db, row, peer, {
|
|
104
|
+
policy = 'blocked-or-stale',
|
|
105
|
+
staleAfter = activePeerTtl,
|
|
106
|
+
ownerEvidence = null,
|
|
107
|
+
ownerRow: suppliedOwnerRow = undefined,
|
|
108
|
+
graceUntil: suppliedGraceUntil = undefined
|
|
109
|
+
} = {}) {
|
|
65
110
|
const normalized = String(policy || 'any').toLowerCase();
|
|
66
111
|
const allowed = new Set(['any', 'blocked', 'stale', 'blocked-or-stale']);
|
|
67
112
|
if (!allowed.has(normalized)) {
|
|
@@ -69,58 +114,133 @@ export function createTaskStore(deps = {}) {
|
|
|
69
114
|
}
|
|
70
115
|
const previousOwner = row.owner || null;
|
|
71
116
|
const blocked = row.status === 'blocked';
|
|
72
|
-
const ownerRow = previousOwner ? db.prepare(
|
|
117
|
+
const ownerRow = suppliedOwnerRow === undefined && previousOwner ? db.prepare(`
|
|
118
|
+
SELECT id, status, pid, pid_start_token, pid_command_hash, last_seen_at
|
|
119
|
+
FROM peers WHERE id = ?
|
|
120
|
+
`).get(previousOwner) : (suppliedOwnerRow || null);
|
|
73
121
|
const ownerAge = ownerRow ? now() - Number(ownerRow.last_seen_at || 0) : null;
|
|
74
|
-
const
|
|
122
|
+
const evidence = ownerEvidence || { state: 'unknown', reason: ownerRow ? 'evidence_not_observed' : 'peer_missing' };
|
|
123
|
+
const ownerDead = evidence.state === 'dead';
|
|
124
|
+
const ownerLive = evidence.state === 'live';
|
|
125
|
+
// hb-05: during a wall-clock grace window (sleep/NTP jump) age is
|
|
126
|
+
// untrustworthy; never treat the owner as stale so a live owner's task is
|
|
127
|
+
// not taken over right after the machine wakes.
|
|
128
|
+
const graceUntil = suppliedGraceUntil === undefined ? readClockGraceUntil(db) : suppliedGraceUntil;
|
|
129
|
+
const graceActive = clockGraceSuppressed(now(), graceUntil);
|
|
130
|
+
const unknownExpired = ownerRow && peerEvidenceAllowsReap(evidence, {
|
|
131
|
+
nowSec: now(),
|
|
132
|
+
lastSeenAt: Number(ownerRow.last_seen_at || 0),
|
|
133
|
+
staleAfterSec: Math.max(staleAfter, UNKNOWN_EVIDENCE_GRACE_SEC),
|
|
134
|
+
graceUntil
|
|
135
|
+
});
|
|
136
|
+
const ownerStale = Boolean(previousOwner && previousOwner !== peer && (
|
|
137
|
+
!ownerRow || ownerDead ||
|
|
138
|
+
(!ownerLive && !graceActive && unknownExpired)
|
|
139
|
+
));
|
|
75
140
|
const alreadyOwner = previousOwner === peer;
|
|
76
141
|
const ok = alreadyOwner ||
|
|
77
142
|
normalized === 'any' ||
|
|
78
143
|
(normalized === 'blocked' && blocked) ||
|
|
79
144
|
(normalized === 'stale' && ownerStale) ||
|
|
80
145
|
(normalized === 'blocked-or-stale' && (blocked || ownerStale));
|
|
81
|
-
return {
|
|
146
|
+
return {
|
|
147
|
+
policy: normalized,
|
|
148
|
+
blocked,
|
|
149
|
+
owner_stale: ownerStale,
|
|
150
|
+
owner_age_sec: ownerAge,
|
|
151
|
+
owner_evidence_state: evidence.state,
|
|
152
|
+
owner_evidence_reason: evidence.reason,
|
|
153
|
+
stale_after_sec: staleAfter,
|
|
154
|
+
ok
|
|
155
|
+
};
|
|
82
156
|
}
|
|
83
157
|
|
|
84
|
-
function takeOverTaskForPeer(db, peer, id, {
|
|
85
|
-
|
|
86
|
-
|
|
158
|
+
function takeOverTaskForPeer(db, peer, id, {
|
|
159
|
+
reason,
|
|
160
|
+
policy = 'blocked-or-stale',
|
|
161
|
+
staleAfter = activePeerTtl,
|
|
162
|
+
source = null,
|
|
163
|
+
ownerEvidence = null,
|
|
164
|
+
ownerEvidenceFor = null
|
|
165
|
+
} = {}) {
|
|
166
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
167
|
+
const subject = takeoverSubject(db, id);
|
|
168
|
+
const row = subject.row;
|
|
87
169
|
if (!row) throw new CliError('NOT_FOUND', `Task #${id} does not exist`);
|
|
88
170
|
if (['done', 'abandoned'].includes(row.status)) {
|
|
89
171
|
throw new CliError('BAD_STATE', `Task #${id} is ${row.status}`);
|
|
90
172
|
}
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
173
|
+
const observedEvidence = row.owner && typeof ownerEvidenceFor === 'function'
|
|
174
|
+
? ownerEvidenceFor(row.owner, row, subject.ownerRow, subject.binding)
|
|
175
|
+
: (ownerEvidence || (subject.ownerRow
|
|
176
|
+
? processEvidenceForPeer(subject.ownerRow)
|
|
177
|
+
: { state: 'unknown', reason: 'peer_missing' }));
|
|
178
|
+
if (observeClockSafety) {
|
|
179
|
+
observeClockSafety({
|
|
180
|
+
db,
|
|
181
|
+
row,
|
|
182
|
+
ownerRow: subject.ownerRow,
|
|
183
|
+
binding: subject.binding,
|
|
184
|
+
evidence: observedEvidence,
|
|
185
|
+
staleAfter
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
let subjectChanged = false;
|
|
189
|
+
const result = tx(db, () => {
|
|
190
|
+
const current = takeoverSubject(db, id);
|
|
191
|
+
if (!sameTakeoverSubject(subject, current)) {
|
|
192
|
+
subjectChanged = true;
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
const policyDetails = takeoverPolicyDetails(db, current.row, peer, {
|
|
196
|
+
policy,
|
|
197
|
+
staleAfter,
|
|
198
|
+
ownerEvidence: observedEvidence,
|
|
199
|
+
ownerRow: current.ownerRow,
|
|
200
|
+
graceUntil: current.graceUntil
|
|
201
|
+
});
|
|
202
|
+
if (!policyDetails.ok) {
|
|
203
|
+
throw new CliError('TAKEOVER_POLICY', `Task #${id} does not match takeover policy ${policyDetails.policy}`, {
|
|
204
|
+
task_id: id,
|
|
205
|
+
status: current.row.status,
|
|
206
|
+
owner: current.row.owner || null,
|
|
207
|
+
policy: policyDetails.policy,
|
|
208
|
+
owner_stale: policyDetails.owner_stale,
|
|
209
|
+
owner_age_sec: policyDetails.owner_age_sec,
|
|
210
|
+
owner_evidence_state: policyDetails.owner_evidence_state,
|
|
211
|
+
owner_evidence_reason: policyDetails.owner_evidence_reason,
|
|
212
|
+
stale_after_sec: policyDetails.stale_after_sec
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
const previousOwner = current.row.owner || null;
|
|
216
|
+
const previousAssignee = current.row.assignee || null;
|
|
217
|
+
const t = now();
|
|
218
|
+
db.prepare(`
|
|
219
|
+
UPDATE tasks
|
|
220
|
+
SET owner = ?, status = 'claimed', claimed_at = COALESCE(claimed_at, ?), updated_at = ?
|
|
221
|
+
WHERE id = ?
|
|
222
|
+
`).run(peer, t, t, id);
|
|
223
|
+
addEvent(db, 'task.takeover', peer, id, {
|
|
224
|
+
previous_owner: previousOwner,
|
|
225
|
+
previous_assignee: previousAssignee,
|
|
226
|
+
reason,
|
|
97
227
|
policy: policyDetails.policy,
|
|
98
228
|
owner_stale: policyDetails.owner_stale,
|
|
99
|
-
|
|
100
|
-
|
|
229
|
+
owner_evidence_state: policyDetails.owner_evidence_state,
|
|
230
|
+
owner_evidence_reason: policyDetails.owner_evidence_reason,
|
|
231
|
+
stale_after_sec: staleAfter,
|
|
232
|
+
...(source ? { source } : {})
|
|
101
233
|
});
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
db.prepare(`
|
|
107
|
-
UPDATE tasks
|
|
108
|
-
SET owner = ?, status = 'claimed', claimed_at = COALESCE(claimed_at, ?), updated_at = ?
|
|
109
|
-
WHERE id = ?
|
|
110
|
-
`).run(peer, t, t, id);
|
|
111
|
-
addEvent(db, 'task.takeover', peer, id, {
|
|
112
|
-
previous_owner: previousOwner,
|
|
113
|
-
previous_assignee: previousAssignee,
|
|
114
|
-
reason,
|
|
115
|
-
policy: policyDetails.policy,
|
|
116
|
-
owner_stale: policyDetails.owner_stale,
|
|
117
|
-
stale_after_sec: staleAfter,
|
|
118
|
-
...(source ? { source } : {})
|
|
234
|
+
if (previousOwner && previousOwner !== peer) {
|
|
235
|
+
sendMessage(db, peer, previousOwner, id, 'task.takeover', `Task #${id} taken over by ${peer}: ${reason}`);
|
|
236
|
+
}
|
|
237
|
+
return db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
|
|
119
238
|
});
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
239
|
+
if (!subjectChanged) return result;
|
|
240
|
+
}
|
|
241
|
+
throw new CliError('SUBJECT_CHANGED', `Task #${id} changed while owner evidence was being observed; retry`, {
|
|
242
|
+
task_id: id,
|
|
243
|
+
retryable: true
|
|
124
244
|
});
|
|
125
245
|
}
|
|
126
246
|
|