@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.
Files changed (108) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +18 -3
  3. package/README.zh-CN.md +17 -3
  4. package/bin/hcc.mjs +3 -5656
  5. package/docs/README.md +8 -0
  6. package/docs/README.zh-CN.md +6 -0
  7. package/docs/architecture.md +29 -18
  8. package/docs/architecture.zh-CN.md +20 -10
  9. package/docs/commands.md +13 -4
  10. package/docs/commands.zh-CN.md +13 -4
  11. package/docs/current-state-review-2026-08.md +341 -0
  12. package/docs/defect-review-2026-07.md +382 -0
  13. package/docs/guide.md +39 -3
  14. package/docs/guide.zh-CN.md +34 -2
  15. package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +294 -0
  16. package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +400 -0
  17. package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +284 -0
  18. package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +303 -0
  19. package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +71 -0
  20. package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +117 -0
  21. package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +120 -0
  22. package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +117 -0
  23. package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +104 -0
  24. package/lib/cli/app.mjs +642 -0
  25. package/lib/cli/commands/coordination.mjs +247 -0
  26. package/lib/cli/commands/doctor.mjs +81 -0
  27. package/lib/cli/commands/gc.mjs +852 -0
  28. package/lib/cli/commands/hook.mjs +205 -0
  29. package/lib/cli/commands/install.mjs +294 -0
  30. package/lib/cli/commands/lifecycle.mjs +101 -0
  31. package/lib/cli/commands/lock.mjs +197 -0
  32. package/lib/cli/commands/misc.mjs +61 -0
  33. package/lib/cli/commands/msg.mjs +152 -0
  34. package/lib/cli/commands/peer.mjs +233 -0
  35. package/lib/cli/commands/query.mjs +96 -0
  36. package/lib/cli/commands/run.mjs +463 -0
  37. package/lib/cli/commands/task.mjs +405 -0
  38. package/lib/cli/commands/team.mjs +131 -0
  39. package/lib/cli/commands/tmux.mjs +838 -0
  40. package/lib/cli/commands/up.mjs +135 -0
  41. package/lib/cli-args.mjs +16 -2
  42. package/lib/coordination-state.mjs +112 -51
  43. package/lib/core/coordination/automation.mjs +5 -8
  44. package/lib/core/coordination/clock-safety.mjs +323 -0
  45. package/lib/core/coordination/event-retention.mjs +17 -0
  46. package/lib/core/coordination/gc-plan.mjs +614 -0
  47. package/lib/core/coordination/lease-renewal.mjs +102 -0
  48. package/lib/core/coordination/lock-evidence.mjs +77 -0
  49. package/lib/core/coordination/optimistic-evidence.mjs +26 -0
  50. package/lib/core/coordination/tasks.mjs +156 -36
  51. package/lib/core/peers/evidence-runtime.mjs +312 -0
  52. package/lib/core/peers/evidence.mjs +103 -0
  53. package/lib/core/peers/hook-owner.mjs +32 -0
  54. package/lib/core/peers/liveness.mjs +44 -4
  55. package/lib/core/peers/peer-helpers.mjs +121 -0
  56. package/lib/core/peers/session.mjs +8 -2
  57. package/lib/core/peers/tmux-safety.mjs +290 -0
  58. package/lib/core/sessions/launch.mjs +13 -1
  59. package/lib/db/connection.mjs +104 -0
  60. package/lib/db/events.mjs +34 -0
  61. package/lib/db/migration-backup.mjs +225 -0
  62. package/lib/db/schema.mjs +102 -5
  63. package/lib/db/stores/peers.mjs +34 -0
  64. package/lib/integrations/peers/identity.mjs +62 -16
  65. package/lib/integrations/shims/script.mjs +111 -9
  66. package/lib/process/identity.mjs +192 -0
  67. package/lib/process/pty-lifecycle.mjs +149 -0
  68. package/lib/runtime/buffer-directory-lease.mjs +47 -0
  69. package/lib/runtime/buffer-evidence.mjs +336 -0
  70. package/lib/runtime/buffer-gc-protocol.mjs +167 -0
  71. package/lib/runtime/buffer-gc.mjs +270 -0
  72. package/lib/runtime/client.mjs +60 -7
  73. package/lib/runtime/fatal-shutdown.mjs +54 -0
  74. package/lib/runtime/project-path.mjs +139 -0
  75. package/lib/runtime/projects.mjs +320 -40
  76. package/lib/runtime/state.mjs +227 -27
  77. package/lib/shared/cleanup-error.mjs +54 -0
  78. package/lib/shared/clock-grace.mjs +106 -0
  79. package/lib/shared/errors.mjs +9 -0
  80. package/lib/shared/file-lock.mjs +370 -0
  81. package/lib/shared/json-file.mjs +6 -2
  82. package/lib/shared/redact.mjs +60 -0
  83. package/lib/shared/socket-lock-worker.mjs +201 -0
  84. package/lib/terminal/tmux.mjs +78 -4
  85. package/lib/tmux.mjs +4 -0
  86. package/lib/ui/help.mjs +53 -15
  87. package/lib/web/api-version.mjs +42 -0
  88. package/lib/web/auto-attach.mjs +185 -0
  89. package/lib/web/buffer-gc-runtime.mjs +175 -0
  90. package/lib/web/cookie-auth.mjs +119 -0
  91. package/lib/web/external-sessions.mjs +254 -0
  92. package/lib/web/http-routes.mjs +542 -0
  93. package/lib/web/http.mjs +135 -6
  94. package/lib/web/liveness-reaper.mjs +145 -0
  95. package/lib/web/peer-actions.mjs +132 -23
  96. package/lib/web/project-contexts.mjs +178 -0
  97. package/lib/web/pty-sessions.mjs +203 -0
  98. package/lib/web/runtime-main.mjs +714 -0
  99. package/lib/web/runtime.mjs +91 -36
  100. package/lib/web/session-serialize.mjs +0 -0
  101. package/lib/web/startup.mjs +333 -0
  102. package/lib/web/tls.mjs +365 -0
  103. package/lib/web/tmux-sessions.mjs +1361 -0
  104. package/lib/web/tmux-stream.mjs +166 -0
  105. package/lib/web/ui-template.mjs +152 -20
  106. package/package.json +5 -2
  107. package/scripts/check-free-identifiers.mjs +290 -0
  108. 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, { policy = 'any', staleAfter = activePeerTtl } = {}) {
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('SELECT id, last_seen_at FROM peers WHERE id = ?').get(previousOwner) : null;
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 ownerStale = Boolean(previousOwner && previousOwner !== peer && (!ownerRow || ownerAge > staleAfter));
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 { policy: normalized, blocked, owner_stale: ownerStale, owner_age_sec: ownerAge, stale_after_sec: staleAfter, ok };
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, { reason, policy = 'any', staleAfter = activePeerTtl, source = null } = {}) {
85
- return tx(db, () => {
86
- const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
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 policyDetails = takeoverPolicyDetails(db, row, peer, { policy, staleAfter });
92
- if (!policyDetails.ok) {
93
- throw new CliError('TAKEOVER_POLICY', `Task #${id} does not match takeover policy ${policyDetails.policy}`, {
94
- task_id: id,
95
- status: row.status,
96
- owner: row.owner || null,
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
- owner_age_sec: policyDetails.owner_age_sec,
100
- stale_after_sec: policyDetails.stale_after_sec
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
- const previousOwner = row.owner || null;
104
- const previousAssignee = row.assignee || null;
105
- const t = now();
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 (previousOwner && previousOwner !== peer) {
121
- sendMessage(db, peer, previousOwner, id, 'task.takeover', `Task #${id} taken over by ${peer}: ${reason}`);
122
- }
123
- return db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
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