@quolu/lattice 0.56.0 → 0.57.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.
@@ -28,7 +28,8 @@ if (typeof instanceToken !== 'string' || !/^[0-9a-f]{64}$/u.test(instanceToken))
28
28
  code: 'BRIDGE_INSTANCE_TOKEN_INVALID', message: 'bridge instance token is invalid' })}\n`);
29
29
  process.exit(1);
30
30
  }
31
- const controller = bridgeRuntimeController({ env, instanceToken });
31
+ const controller = bridgeRuntimeController({ env, instanceToken,
32
+ heartbeat: () => hubHeartbeat.lastHeartbeatSummary() });
32
33
  let timer;
33
34
  let closing = false;
34
35
  let descriptorFingerprint = null;
@@ -140,7 +141,7 @@ timer = setInterval(async () => {
140
141
  try {
141
142
  const heartbeat = await hubHeartbeat.tick({ config });
142
143
  hubHeartbeatError = null;
143
- if (heartbeat?.state === 'unreachable' || heartbeat?.state === 'rejected') {
144
+ if (['unreachable', 'rejected', 'partial'].includes(heartbeat?.state)) {
144
145
  const fingerprint = JSON.stringify(heartbeat);
145
146
  if (fingerprint !== hubHeartbeatError) process.stderr.write(`${fingerprint}\n`);
146
147
  hubHeartbeatError = fingerprint;
@@ -98,6 +98,24 @@ bridgeが無効な間は以下すべてnullで、bridgeを有効にしている
98
98
  lattice bridge reconfigure --json
99
99
  ```
100
100
 
101
+ ### 公開面から自分のprojectが消えた時(`last_heartbeat`)
102
+
103
+ hubへ繋いでいる端末では、`runtime.last_heartbeat`が最後にhubへ名乗った結果を持つ。公開一覧で
104
+ 自分のprojectがofflineになっている時、原因が端末側か配線側かはここで分かれる。
105
+
106
+ | `state` | 意味 | 打つ手 |
107
+ | --- | --- | --- |
108
+ | `accepted` | 全件受理された。公開面に出ていないならhub側の可視性設定を見る | — |
109
+ | `partial` | 一部が他の生きた端末に所有されている。`rejected_projects`が名指しする | 意図した端末なら放置。奪うなら`adopt` |
110
+ | `rejected` | hubがrequest全体を拒否した。`detail`にtyped code | detailのcodeで分岐 |
111
+ | `unreachable` | hubへ届かない。配線かhubの停止 | hubのURLと生死を確認 |
112
+ | `skipped_no_projects` | 配信しているprojectが0件。名乗るものが無い | 正常な静止。公開したいならそのrepoで作業する |
113
+ | `skipped_no_dashboard` | dashboard daemonを観測できない。配信そのものが立っていない | daemonの生死を見る。`todo`系commandを1回打てば起動する |
114
+ | `null` | hub未設定、またはまだ1回も送っていない | — |
115
+
116
+ 名乗る集合はdaemonが実際に配信している集合そのもの(ADR 0165)。登録簿の`last_seen_at`が古い
117
+ ことは露出に影響しない——**人がCLIを叩かなくなっただけで公開面から消えることはない**。
118
+
101
119
  ### 焼き込むnode pathの選び方(ADR 0163)
102
120
 
103
121
  常駐設定へ焼くnodeのpathは、版付きの実体(homebrewの`Cellar/node/<version>/bin/node`、nvm-windowsの
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.56.0",
3
+ "version": "0.57.1",
4
4
  "description": "Schedulability compiler for multi-agent development: observe real code boundaries, refactor the conflicting seam, recompile the plan for parallel execution",
5
5
  "author": {
6
6
  "name": "Quo / クオ at kitepon.dev",
@@ -151,7 +151,19 @@ async function bridgeRuntimeDrift(persistence, runtime) {
151
151
  * mismatched path, or an enabled bridge with no persistence entry at all all
152
152
  * survive until someone reinstalls the entry.
153
153
  */
154
- function bridgeRemedy(persistence, drift) {
154
+ function bridgeRemedy(persistence, drift, runtime) {
155
+ // A hub that refused some of this terminal's projects keeps serving the rest,
156
+ // so nothing else looks wrong — the refused ids simply never reach the
157
+ // published dashboard from here. They are held by another terminal that is
158
+ // still heartbeating; this resolves itself if that terminal goes offline, and
159
+ // otherwise the way out is to stop claiming the id here. (The protocol has an
160
+ // `adopt` field for taking one over deliberately, but nothing populates it —
161
+ // there is no CLI for it, so naming it as a remedy would be naming a door
162
+ // that does not open.)
163
+ const rejected = runtime?.last_heartbeat?.rejected_projects ?? [];
164
+ if (rejected.length > 0) {
165
+ return `lattice todo dashboard remove ${rejected[0]} --json`;
166
+ }
155
167
  if (persistence === null) return null;
156
168
  if (persistence.state === 'unreadable') return RECONFIGURE_COMMAND;
157
169
  // Only reached with the bridge enabled, so "nothing is installed" means the
@@ -167,7 +179,7 @@ async function bridgeDiagnostics({ config, launchAgent, env, runtimeIdentity })
167
179
  const persistence = await bridgePersistence({ launchAgent, env });
168
180
  const runtime = await runtimeIdentity({ env });
169
181
  const drift = await bridgeRuntimeDrift(persistence, runtime);
170
- return { persistence, runtime, drift, remedy: bridgeRemedy(persistence, drift) };
182
+ return { persistence, runtime, drift, remedy: bridgeRemedy(persistence, drift, runtime) };
171
183
  }
172
184
 
173
185
  function fail(stderr, code, message) {
@@ -307,7 +307,7 @@ async function attest(descriptor) {
307
307
  }
308
308
 
309
309
  const UNIDENTIFIED_RUNTIME = Object.freeze({ pid: null, version: null, node_path: null,
310
- node_version: null, bridge_path: null });
310
+ node_version: null, bridge_path: null, last_heartbeat: null });
311
311
 
312
312
  /**
313
313
  * Who is actually serving right now — pid, product version, node binary — as
@@ -330,7 +330,10 @@ export async function readBridgeRuntimeIdentity({ env = process.env } = {}) {
330
330
  const text = (value) => (typeof value === 'string' ? value : null);
331
331
  return { state: 'running', pid: descriptor.pid, version: text(body.version),
332
332
  node_path: text(body.node_path), node_version: text(body.node_version),
333
- bridge_path: text(body.bridge_path) };
333
+ bridge_path: text(body.bridge_path),
334
+ // Absent from daemons older than 0.55.2 — null then means "this daemon does
335
+ // not report it", not "the hub accepted everything".
336
+ last_heartbeat: body.last_heartbeat ?? null };
334
337
  }
335
338
 
336
339
  async function healthy(descriptor, config) {
@@ -2,8 +2,9 @@
2
2
  * Terminal-side bridge-hub heartbeat client (bh3).
3
3
  *
4
4
  * Wires the pure wire contract in `bridge-hub-protocol.mjs` (bh1) around this
5
- * terminal's own state: a persisted terminal identity, the locally active
6
- * project set (`todo-dashboard-registry.mjs`), and the configured hub origin
5
+ * terminal's own state: a persisted terminal identity, the project set this
6
+ * terminal is actually serving (asked of the dashboard daemon itself, ADR
7
+ * 0165), and the configured hub origin
7
8
  * (`bridge-config.mjs`'s `hub` field). It sends `POST /__lattice/hub/register`
8
9
  * on `bridge-hub-server.mjs`'s (bh2) contract — the request body is the raw
9
10
  * `lattice.bridge_hub_registration_request.v1` object, unmodified in transit.
@@ -21,7 +22,7 @@ import path from 'node:path';
21
22
 
22
23
  import { bridgeConfigPaths } from './bridge-config.mjs';
23
24
  import { BRIDGE_HUB_HEARTBEAT_INTERVAL_MS, validateBridgeHubRegistrationRequest } from './bridge-hub-protocol.mjs';
24
- import { readActiveTodoDashboardProjects } from './todo-dashboard-registry.mjs';
25
+ import { readServedTodoDashboardProjectIds } from './todo-dashboard-registry.mjs';
25
26
 
26
27
  export { BRIDGE_HUB_HEARTBEAT_INTERVAL_MS };
27
28
 
@@ -50,8 +51,8 @@ function terminalIdentityPath(env) {
50
51
  * restarts. It must survive restarts: registration is a full-state
51
52
  * reconciliation keyed by terminal_id (ADR 0162 Decision 4), so a fresh id on
52
53
  * every daemon start would make the hub see "a new terminal" contesting the
53
- * same project_ids the old id still owns until its TTL lapses — a
54
- * self-inflicted `BRIDGE_HUB_PROJECT_CONFLICT`.
54
+ * same project_ids the old id still owns until its TTL lapses — self-inflicted
55
+ * contention, answered now by a `rejected` entry rather than a failed request.
55
56
  */
56
57
  export async function readOrCreateBridgeHubTerminalId({ env = process.env } = {}) {
57
58
  const refs = bridgeConfigPaths(env);
@@ -130,9 +131,46 @@ export async function sendBridgeHubHeartbeat({ hubUrl, request, fetchImpl = fetc
130
131
  if (response.status !== 200) {
131
132
  return { schema: HEARTBEAT_RESULT_SCHEMA, state: 'rejected', status: response.status, detail: body };
132
133
  }
134
+ // A hub that accepted some ids and refused others answers 200 with a non-empty
135
+ // `rejected`. Calling that plain 'accepted' would hide exactly the case this
136
+ // protocol change exists to make visible: a project of ours is live on another
137
+ // terminal and will never reach the published view from here until someone
138
+ // adopts it. Give it its own state so `bridge status` can report it without
139
+ // having to know the registration result's shape.
140
+ const rejected = Array.isArray(body?.rejected) ? body.rejected : [];
141
+ if (rejected.length > 0) {
142
+ return { schema: HEARTBEAT_RESULT_SCHEMA, state: 'partial', result: body };
143
+ }
133
144
  return { schema: HEARTBEAT_RESULT_SCHEMA, state: 'accepted', result: body };
134
145
  }
135
146
 
147
+ /**
148
+ * Compact projection of the last heartbeat, for the daemon's attested health
149
+ * response and from there `lattice bridge status`.
150
+ *
151
+ * Until now a hub that refused this terminal's projects said so only in the
152
+ * daemon's stderr, and the bridge LaunchAgent has no StandardErrorPath — so the
153
+ * only symptom an operator ever saw was a project that silently never appeared
154
+ * on the published dashboard (2026-08-10). The result has to reach a surface a
155
+ * person actually reads.
156
+ */
157
+ export function bridgeHubHeartbeatSummary(result, at = null) {
158
+ if (result === null || result === undefined) return null;
159
+ const rejected = Array.isArray(result.result?.rejected) ? result.result.rejected : [];
160
+ const reclaimed = Array.isArray(result.result?.reclaimed_from_offline)
161
+ ? result.result.reclaimed_from_offline : [];
162
+ const detail = result.detail === undefined || result.detail === null ? null
163
+ : (typeof result.detail === 'string' ? result.detail : JSON.stringify(result.detail)).slice(0, 300);
164
+ return {
165
+ state: result.state,
166
+ at,
167
+ rejected_projects: rejected.map((entry) => entry.project_id)
168
+ .sort((left, right) => left.localeCompare(right, 'en')),
169
+ reclaimed_projects: [...reclaimed],
170
+ detail,
171
+ };
172
+ }
173
+
136
174
  /**
137
175
  * Periodic controller for the bridge daemon's own poll loop
138
176
  * (`bin/lattice-bridge.mjs`). Call `tick({ config })` on every iteration; it
@@ -143,28 +181,42 @@ export async function sendBridgeHubHeartbeat({ hubUrl, request, fetchImpl = fetc
143
181
  export function createBridgeHubHeartbeatController({
144
182
  env = process.env, fetchImpl = fetch, now = () => Date.now(),
145
183
  intervalMs = BRIDGE_HUB_HEARTBEAT_INTERVAL_MS,
146
- readActiveProjects = readActiveTodoDashboardProjects,
184
+ readServedProjectIds = readServedTodoDashboardProjectIds,
147
185
  } = {}) {
148
186
  let lastSentAt = null;
149
187
  let lastResult = null;
188
+ let lastResultAt = null;
189
+ const record = (result, atMs) => {
190
+ lastResult = result;
191
+ lastResultAt = new Date(atMs).toISOString();
192
+ return result;
193
+ };
150
194
  return Object.freeze({
151
195
  async tick({ config }) {
152
- if (config?.hub == null) { lastSentAt = null; lastResult = null; return null; }
196
+ if (config?.hub == null) {
197
+ lastSentAt = null; lastResult = null; lastResultAt = null; return null;
198
+ }
153
199
  const nowMs = now();
154
200
  if (lastSentAt !== null && nowMs - lastSentAt < intervalMs) return lastResult;
155
201
  lastSentAt = nowMs;
156
- const projects = await readActiveProjects({ env });
157
- if (projects.length === 0) {
158
- lastResult = { schema: HEARTBEAT_RESULT_SCHEMA, state: 'skipped_no_projects' };
159
- return lastResult;
202
+ // 名乗る集合は、この端末が実際に配信している集合そのものを使う。登録簿から別途
203
+ // 数え直すと、配信している側と名乗る側が別々の基準を持ち、必ずずれる(ADR 0165)。
204
+ const projectIds = await readServedProjectIds({ env });
205
+ if (projectIds === null) {
206
+ return record({ schema: HEARTBEAT_RESULT_SCHEMA, state: 'skipped_no_dashboard' }, nowMs);
207
+ }
208
+ if (projectIds.length === 0) {
209
+ return record({ schema: HEARTBEAT_RESULT_SCHEMA, state: 'skipped_no_projects' }, nowMs);
160
210
  }
161
211
  const terminalId = await readOrCreateBridgeHubTerminalId({ env });
162
212
  const request = buildBridgeHubRegistrationRequest({
163
- terminalId, port: config.listen.port, projectIds: projects.map((project) => project.project_id),
213
+ terminalId, port: config.listen.port, projectIds,
164
214
  });
165
- lastResult = await sendBridgeHubHeartbeat({ hubUrl: config.hub.url, request, fetchImpl });
166
- return lastResult;
215
+ return record(
216
+ await sendBridgeHubHeartbeat({ hubUrl: config.hub.url, request, fetchImpl }), nowMs,
217
+ );
167
218
  },
168
219
  lastHeartbeatResult: () => lastResult,
220
+ lastHeartbeatSummary: () => bridgeHubHeartbeatSummary(lastResult, lastResultAt),
169
221
  });
170
222
  }
@@ -98,11 +98,12 @@ function validRegistry(registry) {
98
98
  /**
99
99
  * Apply one registration/heartbeat call to a registry snapshot. Pure: takes the
100
100
  * current entries and returns the next ones plus a result summary, mutating
101
- * nothing. Throws `BridgeHubProtocolError` for an invalid request or an
102
- * unresolved project_id conflict; on throw, `registry` is guaranteed untouched
103
- * because nothing is written until every conflict check has passed.
101
+ * nothing. Throws `BridgeHubProtocolError` only for a malformed registry or
102
+ * request; a project_id already held by another terminal is no longer fatal —
103
+ * see the contention rules inline.
104
104
  */
105
- export function applyBridgeHubRegistration({ registry, request, remoteAddress, now = new Date() }) {
105
+ export function applyBridgeHubRegistration({ registry, request, remoteAddress, now = new Date(),
106
+ ttlMs = BRIDGE_HUB_HEARTBEAT_TTL_MS }) {
106
107
  if (!validRegistry(registry)) {
107
108
  throw new BridgeHubProtocolError('BRIDGE_HUB_REGISTRY_INVALID', 'bridge hub registry is invalid');
108
109
  }
@@ -119,30 +120,44 @@ export function applyBridgeHubRegistration({ registry, request, remoteAddress, n
119
120
  const requestedSet = new Set(projectIds);
120
121
  const adoptSet = new Set(adopt);
121
122
 
122
- const conflicts = registry
123
- .filter((entry) => requestedSet.has(entry.project_id)
124
- && entry.terminal_id !== terminalId && !adoptSet.has(entry.project_id))
125
- .map((entry) => ({ project_id: entry.project_id, owning_terminal_id: entry.terminal_id }));
126
- if (conflicts.length > 0) {
127
- throw new BridgeHubProtocolError('BRIDGE_HUB_PROJECT_CONFLICT',
128
- 'one or more project_ids are already owned by a different terminal',
129
- {
130
- conflicts: conflicts.sort((left, right) => left.project_id.localeCompare(right.project_id, 'en')),
131
- next_action: 're-register naming the conflicting project_ids in adopt',
132
- });
133
- }
123
+ // A heartbeat carries the terminal's whole active set, so one contested
124
+ // project_id used to fail the entire request and strand every unrelated
125
+ // project on that terminal — invisibly, since the daemon only logs the
126
+ // rejection to a stderr nobody reads. Contested ids are now settled one at a
127
+ // time and the rest are accepted:
128
+ //
129
+ // owner offline (no heartbeat within the TTL) the entry is very likely a
130
+ // leftover from a terminal that moved on, so the live claimant takes it
131
+ // over. Nothing else ever retracts it: entries are never expired.
132
+ // owner online the project genuinely belongs to another live terminal.
133
+ // Reject just that id and report it, so the operator can adopt on purpose.
134
+ // Handing it over unconditionally would make two live terminals trade
135
+ // ownership every heartbeat and flip the published route back and forth.
136
+ const claimed = registry.filter((entry) => requestedSet.has(entry.project_id)
137
+ && entry.terminal_id !== terminalId && !adoptSet.has(entry.project_id));
138
+ const offline = (entry) => now.getTime() - Date.parse(entry.last_seen_at) > ttlMs;
139
+ const rejected = claimed.filter((entry) => !offline(entry))
140
+ .map((entry) => ({ project_id: entry.project_id, owning_terminal_id: entry.terminal_id }))
141
+ .sort((left, right) => left.project_id.localeCompare(right.project_id, 'en'));
142
+ const reclaimed = sorted(claimed.filter(offline).map((entry) => entry.project_id));
143
+
144
+ const rejectedSet = new Set(rejected.map((entry) => entry.project_id));
145
+ const acceptedIds = projectIds.filter((projectId) => !rejectedSet.has(projectId));
146
+ const acceptedSet = new Set(acceptedIds);
134
147
 
135
148
  const registeredAtByProject = new Map(registry
136
149
  .filter((entry) => entry.terminal_id === terminalId)
137
150
  .map((entry) => [entry.project_id, entry.registered_at]));
138
- const adopted = sorted(projectIds.filter((projectId) => adoptSet.has(projectId)
151
+ const adopted = sorted(acceptedIds.filter((projectId) => adoptSet.has(projectId)
139
152
  && !registeredAtByProject.has(projectId)));
140
153
  // Full-state reconciliation: every entry this terminal owned is dropped, then
141
- // rebuilt from `projectIds`. A project omitted from this call — dropped
154
+ // rebuilt from the accepted ids. A project omitted from this call — dropped
142
155
  // locally, or never re-sent after a crash — is released, not left as a
143
- // phantom no future heartbeat can retract.
144
- const others = registry.filter((entry) => entry.terminal_id !== terminalId && !requestedSet.has(entry.project_id));
145
- const mine = projectIds.map((projectId) => ({
156
+ // phantom no future heartbeat can retract. Rejected ids stay with their live
157
+ // owner, so `others` keeps them.
158
+ const others = registry.filter((entry) => entry.terminal_id !== terminalId
159
+ && !acceptedSet.has(entry.project_id));
160
+ const mine = acceptedIds.map((projectId) => ({
146
161
  project_id: projectId,
147
162
  terminal_id: terminalId,
148
163
  display_name: displayName,
@@ -157,12 +172,18 @@ export function applyBridgeHubRegistration({ registry, request, remoteAddress, n
157
172
  return {
158
173
  registry: nextRegistry,
159
174
  result: {
160
- schema: 'lattice.bridge_hub_registration_result.v1',
175
+ // v2: partial acceptance. v1 answered a contested id with a 409 that threw
176
+ // the whole request away; v2 accepts what it can and names what it could
177
+ // not. A v1 terminal reading a v2 result sees a 200 and simply does not
178
+ // notice `rejected` — still strictly better than losing every project.
179
+ schema: 'lattice.bridge_hub_registration_result.v2',
161
180
  terminal_id: terminalId,
162
181
  address: remoteAddress,
163
182
  port,
164
- registered: sorted(projectIds),
183
+ registered: sorted(acceptedIds),
165
184
  adopted,
185
+ rejected,
186
+ reclaimed_from_offline: reclaimed,
166
187
  },
167
188
  };
168
189
  }
@@ -170,8 +191,9 @@ export function applyBridgeHubRegistration({ registry, request, remoteAddress, n
170
191
  /**
171
192
  * Read-time projection for the aggregate `/projects/` view and per-project
172
193
  * routing. Never mutates or drops entries — a terminal that stopped
173
- * heartbeating shows as 'offline' forever, not gone, until it either
174
- * re-registers or a different terminal explicitly adopts the project.
194
+ * heartbeating shows as 'offline' forever, not gone, until it re-registers, a
195
+ * different terminal explicitly adopts the project, or a different terminal
196
+ * claims it while this one is past the TTL (see applyBridgeHubRegistration).
175
197
  */
176
198
  export function projectBridgeHubRegistry({ registry, now = new Date(), ttlMs = BRIDGE_HUB_HEARTBEAT_TTL_MS }) {
177
199
  if (!validRegistry(registry)) {
@@ -436,14 +436,17 @@ export async function startBridgeHubServer({
436
436
  try {
437
437
  result = await registrationLock(async () => {
438
438
  const entries = await store.read();
439
- const applied = applyBridgeHubRegistration({ registry: entries, request, remoteAddress, now: now() });
439
+ const applied = applyBridgeHubRegistration({
440
+ registry: entries, request, remoteAddress, now: now(), ttlMs,
441
+ });
440
442
  await store.write(applied.registry);
441
443
  return applied.result;
442
444
  });
443
445
  } catch (error) {
444
446
  if (error instanceof BridgeHubProtocolError) {
445
- const status = error.code === 'BRIDGE_HUB_PROJECT_CONFLICT' ? 409
446
- : error.code === 'BRIDGE_HUB_REGISTRATION_INVALID' ? 400 : 500;
447
+ // A contested project_id no longer throws — it comes back in the 200
448
+ // body's `rejected`. Only a malformed request or registry lands here.
449
+ const status = error.code === 'BRIDGE_HUB_REGISTRATION_INVALID' ? 400 : 500;
447
450
  respondError(response, status, error.code, error.detail ?? null);
448
451
  return;
449
452
  }
@@ -193,6 +193,7 @@ export async function startBridgeServer({
193
193
  instanceToken = null,
194
194
  resolveUpstream = (upstream) => resolveBridgeUpstream(upstream, { env }),
195
195
  interfaces = networkInterfaces(),
196
+ heartbeat = () => null,
196
197
  } = {}) {
197
198
  if (config?.enabled !== true) throw new BridgeConfigError('BRIDGE_DISABLED', 'bridge is disabled');
198
199
  if (!Array.isArray(config.allowed_hosts) || config.allowed_hosts.length === 0) {
@@ -240,7 +241,11 @@ export async function startBridgeServer({
240
241
  address: currentConfig.listen.address, port: currentConfig.listen.port,
241
242
  updated_at: currentConfig.updated_at ?? null,
242
243
  version: packageJson.version, node_path: process.execPath,
243
- node_version: process.version, bridge_path: process.argv[1] ?? null }
244
+ node_version: process.version, bridge_path: process.argv[1] ?? null,
245
+ // The hub's answer to our last registration. Nothing else carries it
246
+ // out of the daemon: the LaunchAgent has no StandardErrorPath, so a
247
+ // hub refusing our projects was previously invisible everywhere.
248
+ last_heartbeat: heartbeat() }
244
249
  : { schema: 'lattice.bridge_health.v1', status: 'available' })}\n`);
245
250
  return;
246
251
  }
@@ -321,6 +326,7 @@ export function bridgeRuntimeController({
321
326
  register = registerBridgeUpstream,
322
327
  report = (line) => process.stderr.write(`${line}\n`),
323
328
  readInterfaces = () => networkInterfaces(),
329
+ heartbeat = () => null,
324
330
  } = {}) {
325
331
  let active = null;
326
332
  let fingerprint = null;
@@ -371,7 +377,7 @@ export function bridgeRuntimeController({
371
377
  fingerprint = next;
372
378
  return active;
373
379
  }
374
- const replacement = await startBridgeServer({ config, env, instanceToken, interfaces });
380
+ const replacement = await startBridgeServer({ config, env, instanceToken, interfaces, heartbeat });
375
381
  const previous = active;
376
382
  active = replacement;
377
383
  fingerprint = next;
package/src/cli-help.mjs CHANGED
@@ -43,6 +43,7 @@ Commands:
43
43
  intake --run .lattice/runs/<id> --task <task_id>
44
44
  intake release --run .lattice/runs/<id> --task <task_id>
45
45
  intake attach --run .lattice/runs/<id> --task <task_id> --input <worker.json>
46
+ intake detach --run .lattice/runs/<id> --task <task_id>
46
47
  intake intervention --run .lattice/runs/<id> --task <task_id>
47
48
  intake accept --run .lattice/runs/<id> --task <task_id>
48
49
  adapter register --input <descriptor.json>
@@ -194,6 +195,7 @@ const SUBCOMMAND_USAGE = Object.freeze({
194
195
  'run intake': 'run intake --run .lattice/runs/<id> --task <task_id>',
195
196
  'run intake release': 'run intake release --run .lattice/runs/<id> --task <task_id>',
196
197
  'run intake attach': 'run intake attach --run .lattice/runs/<id> --task <task_id> --input <worker.json>',
198
+ 'run intake detach': 'run intake detach --run .lattice/runs/<id> --task <task_id>',
197
199
  'run intake intervention': 'run intake intervention --run .lattice/runs/<id> --task <task_id>',
198
200
  'run intake accept': 'run intake accept --run .lattice/runs/<id> --task <task_id>',
199
201
  'run adapter register': 'run adapter register --input <descriptor.json> | --schema --json',
@@ -100,6 +100,7 @@ import {
100
100
  acceptPullTask,
101
101
  attachPullWorker,
102
102
  closePullRun,
103
+ detachPullWorker,
103
104
  inspectRunMode as inspectPullRunMode,
104
105
  intakePullTask,
105
106
  interventionPullTask,
@@ -4411,6 +4412,15 @@ export async function runRuntimeCli({ argv, cwd, stdout, stderr }) {
4411
4412
  const output = await releasePullTask({ runDir, taskId: argv[6] });
4412
4413
  stdout.write(`${JSON.stringify(output)}\n`); return 0;
4413
4414
  };
4415
+ } else if (argv.length === 7
4416
+ && argv[0] === 'run' && argv[1] === 'intake' && argv[2] === 'detach'
4417
+ && argv[3] === '--run' && typeof argv[4] === 'string' && argv[4].length > 0
4418
+ && argv[5] === '--task' && typeof argv[6] === 'string' && argv[6].length > 0) {
4419
+ action = async () => {
4420
+ const { runDir } = await resolveRunStore(cwd, argv[4]);
4421
+ const output = await detachPullWorker({ runDir, taskId: argv[6] });
4422
+ stdout.write(`${JSON.stringify(output)}\n`); return 0;
4423
+ };
4414
4424
  } else if (argv.length === 9
4415
4425
  && argv[0] === 'run' && argv[1] === 'intake' && argv[2] === 'attach'
4416
4426
  && argv[3] === '--run' && typeof argv[4] === 'string' && argv[4].length > 0
@@ -216,6 +216,7 @@ function project(events, meta) {
216
216
  sequence: event.sequence,
217
217
  ...structuredClone(event.payload),
218
218
  worker: null,
219
+ worker_detached: false,
219
220
  accepted: null,
220
221
  });
221
222
  } else if (event.kind === 'intake_refreshed') {
@@ -235,6 +236,13 @@ function project(events, meta) {
235
236
  } else if (event.kind === 'worker_stopped' || event.kind === 'worker_resumed') {
236
237
  const intake = intakes.get(event.task_id);
237
238
  if (intake?.worker) intake.worker.stopped = event.kind === 'worker_stopped';
239
+ } else if (event.kind === 'worker_detached') {
240
+ const intake = intakes.get(event.task_id);
241
+ // `worker_detached` is remembered separately from `worker === null`: an
242
+ // intake that never had a worker and one whose worker was deliberately
243
+ // unbound look identical otherwise, and only the latter may be released
244
+ // by an operator who is not the original seat.
245
+ if (intake) { intake.worker = null; intake.worker_detached = true; }
238
246
  } else if (event.kind === 'task_accepted') {
239
247
  const intake = intakes.get(event.task_id);
240
248
  if (intake) intake.accepted = structuredClone(event.payload);
@@ -287,6 +295,20 @@ function sameActor(left, right) {
287
295
  return left?.host === right.host && left?.session === right.session && left?.agent === right.agent;
288
296
  }
289
297
 
298
+ /**
299
+ * The acting actor when the caller has one, `null` when it does not.
300
+ *
301
+ * Recovery is performed by an operator who is by definition NOT the seat being
302
+ * recovered — the seat may be SIGSTOP'd, dead, or from a session that no longer
303
+ * exists. Demanding the seat's own env-derived identity on a recovery command
304
+ * would mean only the frozen seat could unfreeze itself, which is the trap this
305
+ * whole path exists to remove. The actor is recorded when present so the trail
306
+ * survives, but it is not an authorization gate here.
307
+ */
308
+ function optionalActorFromEnvironment(environment = process.env) {
309
+ try { return actorFromEnvironment(environment); } catch { return null; }
310
+ }
311
+
290
312
  function activeMember(store, planKey) {
291
313
  const member = store.members?.find((entry) => entry.plan?.plan_key === planKey);
292
314
  if (!member) fail('PLAN_NOT_ACTIVE', `active planが見つからない: ${planKey}`);
@@ -805,10 +827,15 @@ export async function releasePullTask({ runDir, taskId, environment = process.en
805
827
  if (intake.worker !== null) {
806
828
  fail('INTAKE_WORKER_ATTACHED', 'workerがattach済みのintakeはreleaseできない', {
807
829
  task_id: taskId,
808
- next_action: 'workerを安全に停止・detachできる正規経路を先に使う',
830
+ next_action: 'lattice run intake detach --run <run> --task <task> --json',
809
831
  });
810
832
  }
811
- if (!sameActor(intake.actor, actor)) {
833
+ // The actor gate protects a seat's own in-progress work from another seat.
834
+ // Once the worker is detached there is no seat left to protect, and demanding
835
+ // the original session's identity would block the operator performing the
836
+ // recovery — the seat that owned this intake is precisely the one that is
837
+ // gone. The releasing actor is recorded either way.
838
+ if (intake.worker_detached !== true && !sameActor(intake.actor, actor)) {
812
839
  fail('INTAKE_BINDING_CONFLICT', 'intakeを作成したactorだけがreleaseできる', {
813
840
  task_id: taskId,
814
841
  });
@@ -930,6 +957,66 @@ export async function attachPullWorker({ runDir, taskId, input, environment = pr
930
957
  } finally { await lock.release(); }
931
958
  }
932
959
 
960
+ /**
961
+ * Unbind a worker from its intake, releasing it first if the hold stopped it.
962
+ *
963
+ * This is the door `releasePullTask`'s own `next_action` already names. Without
964
+ * it a hold that can never clear leaves the seat SIGSTOP'd with no legitimate
965
+ * exit: `run close` refuses while the intake is unaccepted, `run intake release`
966
+ * refuses while a worker is attached, and `run abandon` is legacy-only. A shared
967
+ * pull run over a repo with no indexable code reached exactly that state
968
+ * (2026-08-10) — the sensor could not stamp an empty index, so the boundary was
969
+ * never verifiable and the hold was permanent.
970
+ *
971
+ * Authorization is process identity, not the actor: `signalAttachedWorker`
972
+ * re-verifies lstart/argv/pgid against the recorded binding before signalling,
973
+ * which is strictly stronger than an env-derived actor claim (env vars are
974
+ * trivially settable; a process's start identity is not). See
975
+ * `optionalActorFromEnvironment` for why the seat's own identity cannot be the
976
+ * gate on a recovery command.
977
+ */
978
+ export async function detachPullWorker({ runDir, taskId, environment = process.env }) {
979
+ if (!identifier(taskId)) fail('INVALID_TASK_ID', 'task idが不正');
980
+ const actor = optionalActorFromEnvironment(environment);
981
+ const lock = await acquirePullLock(runDir, 'detach', `${taskId}-${randomUUID()}`);
982
+ try {
983
+ let current = await readPullStore(runDir);
984
+ const state = project(current.events, current.meta);
985
+ if (state.closed) fail('RUN_CLOSED', 'closed pull runのworkerをdetachできない');
986
+ const intake = state.intakes.find((entry) => entry.task_id === taskId);
987
+ if (intake === undefined) fail('INTAKE_NOT_FOUND', 'active intakeが存在しない', { task_id: taskId });
988
+ if (intake.accepted !== null) {
989
+ fail('INTAKE_ALREADY_ACCEPTED', 'accepted intakeのworkerはdetachできない', { task_id: taskId });
990
+ }
991
+ if (intake.worker === null) {
992
+ fail('INTAKE_WORKER_ABSENT', 'attach済みworkerが無い', { task_id: taskId });
993
+ }
994
+ // Resume before unbinding. Once the binding is gone nothing in this store can
995
+ // name that pid again, so a worker left stopped here would be unreachable by
996
+ // any future command — the same one-way door, one step further along.
997
+ const resumed = intake.worker.stopped;
998
+ if (resumed) {
999
+ await signalAttachedWorker(intake, 'SIGCONT');
1000
+ current = await appendEvent(runDir, current, buildEvent({
1001
+ events: current.events, meta: current.meta, kind: 'worker_resumed', taskId,
1002
+ payload: { released_by: 'worker_detach' },
1003
+ }));
1004
+ }
1005
+ current = await appendEvent(runDir, current, buildEvent({
1006
+ events: current.events, meta: current.meta, kind: 'worker_detached', taskId,
1007
+ payload: { detached_by: actor, pid: intake.worker.pid, resumed },
1008
+ }));
1009
+ const output = {
1010
+ schema: 'lattice.pull_worker_detach_result.v1', outcome: 'detached',
1011
+ run_id: state.run_id, task_id: taskId, pid: intake.worker.pid, resumed,
1012
+ next_action: 'lattice run intake release --run <run> --task <task> --json',
1013
+ result_digest: '',
1014
+ };
1015
+ output.result_digest = digestArtifact(output);
1016
+ return output;
1017
+ } finally { await lock.release(); }
1018
+ }
1019
+
933
1020
  function observationModel(state) {
934
1021
  const intakes = state.intakes.filter((entry) => (
935
1022
  entry.accepted === null && entry.intervention.state !== 'hold'
@@ -1105,7 +1192,13 @@ export async function interventionPullTask(runDir, taskId) {
1105
1192
  if (!intake) fail('TASK_NOT_INTAKED', `taskがintakeされていない: ${taskId}`);
1106
1193
  const output = { schema: 'lattice.pull_intervention.v1', run_id: stored.meta.run_id,
1107
1194
  task_id: taskId, ...structuredClone(intake.intervention),
1108
- worker_attached: intake.worker !== null, worker_stopped: intake.worker?.stopped ?? false };
1195
+ worker_attached: intake.worker !== null, worker_stopped: intake.worker?.stopped ?? false,
1196
+ // `next_action` says how to clear the hold. When the seat is already frozen
1197
+ // the operator also needs to know how to get out if it never clears — the
1198
+ // absence of that answer is what turned one unverifiable boundary into an
1199
+ // unrecoverable run (2026-08-10).
1200
+ recovery: intake.worker?.stopped === true
1201
+ ? 'lattice run intake detach --run <run> --task <task> --json' : null };
1109
1202
  output.result_digest = digestArtifact(output); return output;
1110
1203
  }
1111
1204
 
package/src/todo-cli.mjs CHANGED
@@ -2676,6 +2676,50 @@ async function serveGantt({ repoRoot, port, stdout, env, scope = DEFAULT_GANTT_S
2676
2676
  return null;
2677
2677
  }
2678
2678
 
2679
+ /**
2680
+ * Whether this command should mark the repo as an actively-worked project.
2681
+ *
2682
+ * Being "active" is not a local convenience: the bridge heartbeat sends every
2683
+ * active project to the hub, and the hub routes the published dashboard from
2684
+ * that set. So a command that flips this flag is claiming, on the operator's
2685
+ * behalf, that this terminal serves this project — for the next two hours.
2686
+ *
2687
+ * A read must never make that claim. Running `lattice todo status` inside a
2688
+ * clone to look at it once used to register the project here, contest it with
2689
+ * whichever terminal actually serves it, and (before partial acceptance landed)
2690
+ * take that terminal's whole project set down with it. The diagnosing operator
2691
+ * extended the very outage they were investigating (2026-08-10).
2692
+ *
2693
+ * The rule is an allowlist of store writes, not a denylist of known-bad reads.
2694
+ * The denylist it replaces had grown one incident at a time — `verify` was
2695
+ * added to it the day before, after `todo verify` turned out to be unreachable
2696
+ * for exactly the store inconsistency it exists to diagnose — and every read
2697
+ * added later would have defaulted back to claiming ownership.
2698
+ *
2699
+ * `dashboard` commands are excluded: they own the registry themselves, and
2700
+ * pre-registering the current repo ahead of `dashboard remove` would re-add
2701
+ * what the operator is asking to drop.
2702
+ */
2703
+ function writesTodoStore(argv) {
2704
+ const [command, second, third] = argv;
2705
+ if (argv.includes('--schema')) return false;
2706
+ switch (command) {
2707
+ case 'note': return second !== 'list';
2708
+ case 'migrate': return !argv.includes('--dry-run');
2709
+ case 'snapshot': return argv.includes('--rebuild');
2710
+ case 'independence': return second === 'compile'
2711
+ || (second === 'witness' && ['migrate', 'scaffold'].includes(third));
2712
+ case 'seam-proposal': return ['compile', 'apply', 'land'].includes(second);
2713
+ case 'evidence': return second === 'promote';
2714
+ case 'dependency': return second === 'connect';
2715
+ case 'phase': return second !== 'status';
2716
+ case 'start': case 'retract': case 'block': case 'unblock': case 'done':
2717
+ case 'reopen': case 'split': case 'revise': case 'revise-phase': case 'revise-set':
2718
+ return true;
2719
+ default: return false;
2720
+ }
2721
+ }
2722
+
2679
2723
  async function ensureActiveProjectDashboard({ repoRoot, env }) {
2680
2724
  if (env.LATTICE_DASHBOARD_AUTOSTART === '0') return null;
2681
2725
  const actorIdentity = ACTOR_ENV_KEYS.map((key) => env[key]);
@@ -3137,21 +3181,12 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
3137
3181
 
3138
3182
  try {
3139
3183
  const repoRoot = resolveRepoRoot(cwd);
3140
- const ganttCommand = argv[0] === 'gantt';
3141
- const dashboardAdopt = argv[0] === 'dashboard' && argv[1] === 'adopt';
3142
- const migrationDryRun = argv[0] === 'migrate' && argv.includes('--dry-run');
3143
- // `verify` is the store's own read-only recovery diagnostic the command
3144
- // an operator reaches for precisely when the store might be inconsistent.
3145
- // ensureActiveProjectDashboard calls readTodoStoreStable for an unrelated
3146
- // side effect (registering this session as an active dashboard project),
3147
- // and readTodoStoreStable retries a persistent STORE_INCONSISTENT as if it
3148
- // were a transient in-flight write before giving up and reporting a
3149
- // content-free STORE_BUSY. Running that pre-hook ahead of `verify` meant
3150
- // the one command meant to surface the real inconsistency never got to —
3151
- // it died in the same generic way every other command did (2026-08-10 P0:
3152
- // `todo verify` was unreachable for the exact case it exists to diagnose).
3153
- const verifyCommand = argv[0] === 'verify';
3154
- if (!ganttCommand && !dashboardAdopt && !migrationDryRun && !verifyCommand) {
3184
+ // Only a store write claims this terminal as the project's live source; see
3185
+ // `writesTodoStore`. This also keeps the pre-hook away from `verify`, whose
3186
+ // whole job is to diagnose a store too inconsistent for the pre-hook's own
3187
+ // `readTodoStoreStable` to read (2026-08-10 P0: `todo verify` was
3188
+ // unreachable for exactly the case it exists to diagnose).
3189
+ if (writesTodoStore(argv)) {
3155
3190
  await ensureActiveProjectDashboard({ repoRoot, env });
3156
3191
  }
3157
3192
  const result = atomicCommit
@@ -157,6 +157,34 @@ export async function readVisibleTodoDashboardProjects({
157
157
  || left.project_id.localeCompare(right.project_id, 'en'));
158
158
  }
159
159
 
160
+ /**
161
+ * この端末がいま実際に配信しているproject id。登録簿を自分で数え直さず、配信している
162
+ * dashboard daemon自身へ問い、loopback healthをdescriptorと突き合わせてから答える。
163
+ * daemonが居ない・応答しない・descriptorと食い違う時はnull——「配信していない」であって
164
+ * 「0件を配信している」ではない。
165
+ *
166
+ * 公開面へ在ると名乗る集合は、実際に配信している集合と同じでなければならない。別々に
167
+ * 数えると必ずずれる(2026-08-11に実測)。daemonはactive runを持つprojectを配信し続ける
168
+ * のに、hub heartbeatは登録簿の2時間staleだけを見て0件と判断し、送信ごと省いた。中身は
169
+ * 配信できるのに、公開面からは端末ごとofflineになる——障害と見分けがつかない消え方をする。
170
+ */
171
+ export async function readServedTodoDashboardProjectIds({
172
+ env = process.env, fetchImpl = fetch, timeoutMs = 2_000,
173
+ } = {}) {
174
+ let descriptor;
175
+ try { descriptor = await readJson(paths(env).descriptor, null); } catch { return null; }
176
+ if (!validDaemonDescriptor(descriptor)) return null;
177
+ let body;
178
+ try {
179
+ const response = await fetchImpl(`http://127.0.0.1:${descriptor.port}/__lattice/health`,
180
+ { signal: AbortSignal.timeout(timeoutMs) });
181
+ body = response.status === 200 ? await response.json() : null;
182
+ } catch { return null; }
183
+ if (body?.schema !== 'lattice.todo_dashboard_health.v1' || body.pid !== descriptor.pid
184
+ || body.port !== descriptor.port || !Array.isArray(body.project_ids)) return null;
185
+ return body.project_ids.filter(identifier).sort((left, right) => left.localeCompare(right, 'en'));
186
+ }
187
+
160
188
  /**
161
189
  * 見るのはrepo_root directoryの存在だけ。`.lattice`の有無で判定すると、storeを持たない
162
190
  * 生きたrepoまで登録簿から消える。ENOENT/ENOTDIR以外のerrorは「消えた証拠」ではないので