@quolu/lattice 0.56.0 → 0.57.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.
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.56.0",
3
+ "version": "0.57.0",
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) {
@@ -50,8 +50,8 @@ function terminalIdentityPath(env) {
50
50
  * restarts. It must survive restarts: registration is a full-state
51
51
  * reconciliation keyed by terminal_id (ADR 0162 Decision 4), so a fresh id on
52
52
  * 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`.
53
+ * same project_ids the old id still owns until its TTL lapses — self-inflicted
54
+ * contention, answered now by a `rejected` entry rather than a failed request.
55
55
  */
56
56
  export async function readOrCreateBridgeHubTerminalId({ env = process.env } = {}) {
57
57
  const refs = bridgeConfigPaths(env);
@@ -130,9 +130,46 @@ export async function sendBridgeHubHeartbeat({ hubUrl, request, fetchImpl = fetc
130
130
  if (response.status !== 200) {
131
131
  return { schema: HEARTBEAT_RESULT_SCHEMA, state: 'rejected', status: response.status, detail: body };
132
132
  }
133
+ // A hub that accepted some ids and refused others answers 200 with a non-empty
134
+ // `rejected`. Calling that plain 'accepted' would hide exactly the case this
135
+ // protocol change exists to make visible: a project of ours is live on another
136
+ // terminal and will never reach the published view from here until someone
137
+ // adopts it. Give it its own state so `bridge status` can report it without
138
+ // having to know the registration result's shape.
139
+ const rejected = Array.isArray(body?.rejected) ? body.rejected : [];
140
+ if (rejected.length > 0) {
141
+ return { schema: HEARTBEAT_RESULT_SCHEMA, state: 'partial', result: body };
142
+ }
133
143
  return { schema: HEARTBEAT_RESULT_SCHEMA, state: 'accepted', result: body };
134
144
  }
135
145
 
146
+ /**
147
+ * Compact projection of the last heartbeat, for the daemon's attested health
148
+ * response and from there `lattice bridge status`.
149
+ *
150
+ * Until now a hub that refused this terminal's projects said so only in the
151
+ * daemon's stderr, and the bridge LaunchAgent has no StandardErrorPath — so the
152
+ * only symptom an operator ever saw was a project that silently never appeared
153
+ * on the published dashboard (2026-08-10). The result has to reach a surface a
154
+ * person actually reads.
155
+ */
156
+ export function bridgeHubHeartbeatSummary(result, at = null) {
157
+ if (result === null || result === undefined) return null;
158
+ const rejected = Array.isArray(result.result?.rejected) ? result.result.rejected : [];
159
+ const reclaimed = Array.isArray(result.result?.reclaimed_from_offline)
160
+ ? result.result.reclaimed_from_offline : [];
161
+ const detail = result.detail === undefined || result.detail === null ? null
162
+ : (typeof result.detail === 'string' ? result.detail : JSON.stringify(result.detail)).slice(0, 300);
163
+ return {
164
+ state: result.state,
165
+ at,
166
+ rejected_projects: rejected.map((entry) => entry.project_id)
167
+ .sort((left, right) => left.localeCompare(right, 'en')),
168
+ reclaimed_projects: [...reclaimed],
169
+ detail,
170
+ };
171
+ }
172
+
136
173
  /**
137
174
  * Periodic controller for the bridge daemon's own poll loop
138
175
  * (`bin/lattice-bridge.mjs`). Call `tick({ config })` on every iteration; it
@@ -147,24 +184,33 @@ export function createBridgeHubHeartbeatController({
147
184
  } = {}) {
148
185
  let lastSentAt = null;
149
186
  let lastResult = null;
187
+ let lastResultAt = null;
188
+ const record = (result, atMs) => {
189
+ lastResult = result;
190
+ lastResultAt = new Date(atMs).toISOString();
191
+ return result;
192
+ };
150
193
  return Object.freeze({
151
194
  async tick({ config }) {
152
- if (config?.hub == null) { lastSentAt = null; lastResult = null; return null; }
195
+ if (config?.hub == null) {
196
+ lastSentAt = null; lastResult = null; lastResultAt = null; return null;
197
+ }
153
198
  const nowMs = now();
154
199
  if (lastSentAt !== null && nowMs - lastSentAt < intervalMs) return lastResult;
155
200
  lastSentAt = nowMs;
156
201
  const projects = await readActiveProjects({ env });
157
202
  if (projects.length === 0) {
158
- lastResult = { schema: HEARTBEAT_RESULT_SCHEMA, state: 'skipped_no_projects' };
159
- return lastResult;
203
+ return record({ schema: HEARTBEAT_RESULT_SCHEMA, state: 'skipped_no_projects' }, nowMs);
160
204
  }
161
205
  const terminalId = await readOrCreateBridgeHubTerminalId({ env });
162
206
  const request = buildBridgeHubRegistrationRequest({
163
207
  terminalId, port: config.listen.port, projectIds: projects.map((project) => project.project_id),
164
208
  });
165
- lastResult = await sendBridgeHubHeartbeat({ hubUrl: config.hub.url, request, fetchImpl });
166
- return lastResult;
209
+ return record(
210
+ await sendBridgeHubHeartbeat({ hubUrl: config.hub.url, request, fetchImpl }), nowMs,
211
+ );
167
212
  },
168
213
  lastHeartbeatResult: () => lastResult,
214
+ lastHeartbeatSummary: () => bridgeHubHeartbeatSummary(lastResult, lastResultAt),
169
215
  });
170
216
  }
@@ -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