@quolu/lattice 0.69.1 → 0.69.3

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.
@@ -33,6 +33,7 @@ const controller = bridgeRuntimeController({ env, instanceToken,
33
33
  let timer;
34
34
  let closing = false;
35
35
  let descriptorFingerprint = null;
36
+ let heartbeatBinding = null;
36
37
  let checking = false;
37
38
  let failClosedError = null;
38
39
  let hubHeartbeatError = null;
@@ -53,9 +54,9 @@ try {
53
54
  }
54
55
  const config = await readBridgeConfig({ env });
55
56
  if (config === null || !config.enabled) throw Object.assign(new Error('bridge disabled'), { code: 'BRIDGE_DISABLED' });
56
- await controller.reconcile();
57
- await writeBridgeDaemonDescriptor({ config, env });
58
- descriptorFingerprint = config.updated_at;
57
+ const binding = await controller.reconcile();
58
+ await writeBridgeDaemonDescriptor({ config, binding, env });
59
+ descriptorFingerprint = JSON.stringify([config.updated_at, binding.address, binding.port]);
59
60
  } catch (error) {
60
61
  process.stderr.write(`${JSON.stringify({ schema: 'lattice.bridge_daemon_error.v1',
61
62
  code: error?.code ?? 'BRIDGE_DAEMON_FAILED', message: error?.message ?? 'bridge daemon failed' })}\n`);
@@ -126,11 +127,12 @@ timer = setInterval(async () => {
126
127
  await removeBridgeDaemonActiveMarker({ env });
127
128
  process.exit(0);
128
129
  }
129
- await controller.reconcile();
130
+ const binding = await controller.reconcile();
130
131
  failClosedError = null;
131
- if (descriptorFingerprint !== config.updated_at) {
132
- await writeBridgeDaemonDescriptor({ config, env });
133
- descriptorFingerprint = config.updated_at;
132
+ const nextDescriptorFingerprint = JSON.stringify([config.updated_at, binding.address, binding.port]);
133
+ if (descriptorFingerprint !== nextDescriptorFingerprint) {
134
+ await writeBridgeDaemonDescriptor({ config, binding, env });
135
+ descriptorFingerprint = nextDescriptorFingerprint;
134
136
  }
135
137
  // Hub heartbeat failures are reported but never fail-close local traffic:
136
138
  // an unreachable hub is a routing problem for the hub's aggregate view,
@@ -139,7 +141,10 @@ timer = setInterval(async () => {
139
141
  // throws (sendBridgeHubHeartbeat's contract) — only a local error (e.g.
140
142
  // the terminal identity file) reaches this catch.
141
143
  try {
142
- const heartbeat = await hubHeartbeat.tick({ config });
144
+ const bindingFingerprint = JSON.stringify([binding.address, binding.port]);
145
+ const forceHeartbeat = heartbeatBinding !== bindingFingerprint;
146
+ heartbeatBinding = bindingFingerprint;
147
+ const heartbeat = await hubHeartbeat.tick({ config, force: forceHeartbeat });
143
148
  // 直前に書いた指紋は、健全な状態へ戻った時にだけ捨てる。判定より前に捨てると
144
149
  // 比較相手が毎周期nullになり、同じ結果をpoll周期ごとに書き続ける。stderrの
145
150
  // 消費者が居ない常駐(WindowsのStartup launcher、LaunchAgent)では、この
@@ -64,6 +64,18 @@ fileを除去し、JSON結果の`recovery`へ処置を明示する。その後
64
64
  自動化・隔離testではabsoluteな`LATTICE_CONFIG_DIR`で設定rootを変更できる。無効な設定、低いport、
65
65
  使用中の明示port、危険なrequest target、到達不能upstreamはsilent fallbackせずtyped errorを返す。
66
66
 
67
+ ## DHCPによるIP変更への自動追従
68
+
69
+ 設定したIPが端末から消え、同一subnetに次のIPがある場合、bridgeは起動時にも稼働中にも
70
+ そのIPへ自動で待受を移す。設定ファイルのIPを書き換えたり、利用者がdisableとreconfigureを
71
+ 順番に実行したりする必要はない。設定のIPは公開するnetworkの意図を保持し、稼働記録と
72
+ health応答は実際の待受IPを示す。生存確認も実際の待受を使い、正常なbridgeを再起動しない。
73
+
74
+ Hubへ接続している場合は、待受の変更に合わせて登録を更新する。Hubは登録接続の送信元IPを
75
+ 使うため、端末が新しいIPを申告する独自経路は設けない。配信の確認結果は従来どおり
76
+ `lattice.dashboard_delivery.v1`で返す。別subnetやloopbackへの自動切替は行わず、同一subnetに
77
+ 移動先がない場合は`BRIDGE_LISTEN_ADDRESS_ABSENT`を返す。
78
+
67
79
  ## 常駐が黙って死んでいないか確かめる
68
80
 
69
81
  `reachable`は「設定したaddressで誰かが応答しているか」しか答えない。常駐設定(macOSのLaunchAgent、
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.69.1",
3
+ "version": "0.69.3",
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",
@@ -405,6 +405,19 @@ export async function runBridgeCli({ argv, stdout, stderr, env = process.env,
405
405
  await clearStop({ env });
406
406
  const current = await readBridgeConfig({ env });
407
407
  const agentSnapshot = await launchAgent.snapshot({ env });
408
+ let activeBinding = null;
409
+ // DHCP追従済みdaemonの実socketを、手動で設定IPへ昇格させる場合がある。
410
+ // tokenでattest済みのdescriptorだけが、この同一portの予約を省略できる。
411
+ // それ以外の占有は従来どおりBRIDGE_PORT_UNAVAILABLEで止める。
412
+ if (command === 'reconfigure' && options.address !== undefined && options.port === undefined
413
+ && current?.enabled === true && current.listen.port !== undefined) {
414
+ const descriptor = await readBridgeDaemonDescriptor({ env });
415
+ const runtime = descriptor === null ? null : await runtimeIdentity({ env });
416
+ if (runtime?.state === 'running' && descriptor.address === options.address
417
+ && descriptor.port === current.listen.port) {
418
+ activeBinding = { address: descriptor.address, port: descriptor.port };
419
+ }
420
+ }
408
421
  const configured = await configureBridge({
409
422
  address: options.address ?? current?.listen.address,
410
423
  port: options.port === undefined ? (command === 'reconfigure' ? current?.listen.port ?? null : null) : options.port,
@@ -413,6 +426,7 @@ export async function runBridgeCli({ argv, stdout, stderr, env = process.env,
413
426
  allowedHosts: options.allowedHosts.length > 0 ? options.allowedHosts
414
427
  : current?.allowed_hosts?.filter((host) => host !== current.listen.address) ?? [],
415
428
  reuseCurrentPort: options.port === undefined,
429
+ activeBinding,
416
430
  });
417
431
  try {
418
432
  if (configured.hub !== null) await ensureDashboard({ env });
@@ -332,6 +332,7 @@ export async function configureBridge({
332
332
  createCandidateServer = () => createServer(),
333
333
  choosePort = () => randomInt(BRIDGE_PORT_MIN, BRIDGE_PORT_MAX + 1),
334
334
  reuseCurrentPort = true,
335
+ activeBinding = null,
335
336
  } = {}) {
336
337
  const normalized = validateMutation({ address, port, upstream, hub, allowedHosts });
337
338
  const refs = bridgeConfigPaths(env);
@@ -341,7 +342,11 @@ export async function configureBridge({
341
342
  const selectedPort = port ?? (reuseCurrentPort && current?.enabled && current.listen.address === address
342
343
  ? current.listen.port : null);
343
344
  const bindingUnchanged = current?.enabled === true && current.listen.address === address
344
- && current.listen.port === selectedPort;
345
+ && current.listen.port === selectedPort
346
+ // activeBindingはbridge-cliがinstance tokenで本人確認した待受だけを渡す。
347
+ // DHCP追従済みの自分のsocketを再予約すると、無関係なport競合と誤認する。
348
+ || current?.enabled === true && activeBinding?.address === address
349
+ && activeBinding.port === selectedPort;
345
350
  let reservation = null;
346
351
  try {
347
352
  if (!bindingUnchanged) {
@@ -3,12 +3,14 @@ import { randomBytes } from 'node:crypto';
3
3
  import { constants as fsConstants } from 'node:fs';
4
4
  import { chmod, lstat, open, readFile, rename, rm, writeFile } from 'node:fs/promises';
5
5
  import { isIP } from 'node:net';
6
+ import { networkInterfaces } from 'node:os';
6
7
  import path from 'node:path';
7
8
  import { parseTree } from 'jsonc-parser';
8
9
 
9
10
  import {
10
11
  BRIDGE_PORT_MAX, BRIDGE_PORT_MIN, BridgeConfigError, bridgeConfigPaths, readBridgeConfig,
11
12
  } from './bridge-config.mjs';
13
+ import { resolveBridgeListenAddress } from './bridge-address.mjs';
12
14
  import packageJson from '../package.json' with { type: 'json' };
13
15
 
14
16
  const DESCRIPTOR_SCHEMA = 'lattice.bridge_daemon.v1';
@@ -202,7 +204,11 @@ export async function clearBridgeStopControl({ env = process.env } = {}) {
202
204
  export async function requestBridgeDaemonStop({ env = process.env, listen = null } = {}) {
203
205
  let descriptor = null;
204
206
  try { descriptor = await readBridgeDaemonDescriptor({ env }); } catch {}
205
- if (descriptor !== null && await attest(descriptor) === null
207
+ let config = null;
208
+ try { config = await readBridgeConfig({ env }); } catch (error) {
209
+ if (!['BRIDGE_CONFIG_INVALID', 'BRIDGE_CONFIG_MODE_INVALID'].includes(error?.code)) throw error;
210
+ }
211
+ if (descriptor !== null && await attestKnownBinding(descriptor, config) === null
206
212
  && !await bridgeEndpointAvailable({ address: descriptor.address, port: descriptor.port })) {
207
213
  return { state: 'not_running', nonce: null };
208
214
  }
@@ -243,15 +249,22 @@ export async function requestBridgeDaemonStop({ env = process.env, listen = null
243
249
  throw new BridgeConfigError('BRIDGE_DAEMON_STOP_FAILED', 'bridge daemon stop receipt timed out');
244
250
  }
245
251
 
246
- export async function writeBridgeDaemonDescriptor({ config, env = process.env }) {
247
- const instanceToken = env.LATTICE_BRIDGE_INSTANCE_TOKEN;
252
+ export async function writeBridgeDaemonDescriptor({ config, binding = config?.listen, identity = null,
253
+ env = process.env } = {}) {
254
+ const instanceToken = identity?.instance_token ?? env.LATTICE_BRIDGE_INSTANCE_TOKEN;
248
255
  if (typeof instanceToken !== 'string' || !/^[0-9a-f]{64}$/u.test(instanceToken)) {
249
256
  throw new BridgeConfigError('BRIDGE_INSTANCE_TOKEN_INVALID', 'bridge instance token is invalid');
250
257
  }
251
- const descriptor = { schema: DESCRIPTOR_SCHEMA, pid: process.pid,
252
- address: config.listen.address, port: config.listen.port,
258
+ if (isIP(binding?.address) === 0 || !Number.isSafeInteger(binding?.port)
259
+ || binding.port < BRIDGE_PORT_MIN || binding.port > BRIDGE_PORT_MAX
260
+ || !Number.isSafeInteger(identity?.pid ?? process.pid) || (identity?.pid ?? process.pid) <= 0
261
+ || identity !== null && !isIsoTimestamp(identity.started_at)) {
262
+ throw new BridgeConfigError('BRIDGE_DAEMON_DESCRIPTOR_INVALID', 'bridge daemon descriptor identity is invalid');
263
+ }
264
+ const descriptor = { schema: DESCRIPTOR_SCHEMA, pid: identity?.pid ?? process.pid,
265
+ address: binding.address, port: binding.port,
253
266
  config_updated_at: config.updated_at, instance_token: instanceToken,
254
- started_at: new Date().toISOString() };
267
+ started_at: identity?.started_at ?? new Date().toISOString() };
255
268
  await atomicDescriptor(bridgeDaemonDescriptorPath(env), descriptor);
256
269
  await atomicDescriptor(bridgeDaemonActiveMarkerPath(env), {
257
270
  schema: ACTIVE_MARKER_SCHEMA, address: descriptor.address, port: descriptor.port,
@@ -301,10 +314,10 @@ export async function waitForBridgeSocketClose({ listen, timeoutMs = STOP_TIMEOU
301
314
  return !await bridgeEndpointAvailable(listen);
302
315
  }
303
316
 
304
- async function attest(descriptor) {
317
+ async function attest(descriptor, { address = descriptor?.address } = {}) {
305
318
  if (descriptor === null) return null;
306
319
  try {
307
- const response = await fetch(`http://${healthHost(descriptor.address)}:${descriptor.port}/__lattice/bridge-health`, {
320
+ const response = await fetch(`http://${healthHost(address)}:${descriptor.port}/__lattice/bridge-health`, {
308
321
  headers: { 'x-lattice-bridge-instance-token': descriptor.instance_token },
309
322
  signal: AbortSignal.timeout(400),
310
323
  });
@@ -314,6 +327,29 @@ async function attest(descriptor) {
314
327
  } catch { return null; }
315
328
  }
316
329
 
330
+ function currentBinding(config, interfaces = networkInterfaces()) {
331
+ if (config === null || config.enabled !== true) return null;
332
+ const address = resolveBridgeListenAddress({ configured: config.listen.address, interfaces }).effective;
333
+ return address === null ? null : { address, port: config.listen.port };
334
+ }
335
+
336
+ async function attestCurrentBinding(descriptor, config, { interfaces = networkInterfaces() } = {}) {
337
+ const binding = currentBinding(config, interfaces);
338
+ if (descriptor === null || binding === null || descriptor.port !== binding.port) return null;
339
+ const body = await attest(descriptor, { address: binding.address });
340
+ return body === null ? null : { body, binding };
341
+ }
342
+
343
+ // 設定変更から次のreconcileまでの間は、旧socketだけが本人確認に応答することがある。
344
+ // 現在の待受に続いてdescriptorの既知の宛先を確認し、instance tokenによる本人確認を保つ。
345
+ async function attestKnownBinding(descriptor, config, options = {}) {
346
+ const current = await attestCurrentBinding(descriptor, config, options);
347
+ if (current !== null) return current;
348
+ const body = await attest(descriptor);
349
+ return body === null ? null : { body,
350
+ binding: { address: descriptor.address, port: descriptor.port } };
351
+ }
352
+
317
353
  const UNIDENTIFIED_RUNTIME = Object.freeze({ pid: null, version: null, node_path: null,
318
354
  node_version: null, bridge_path: null, last_heartbeat: null });
319
355
 
@@ -326,14 +362,22 @@ const UNIDENTIFIED_RUNTIME = Object.freeze({ pid: null, version: null, node_path
326
362
  * thrown: this feeds `lattice bridge status`, the command an operator reaches
327
363
  * for precisely when something is already broken.
328
364
  */
329
- export async function readBridgeRuntimeIdentity({ env = process.env } = {}) {
365
+ export async function readBridgeRuntimeIdentity({ env = process.env, interfaces = networkInterfaces() } = {}) {
330
366
  let descriptor;
331
367
  try { descriptor = await readBridgeDaemonDescriptor({ env }); } catch (error) {
332
368
  if (error?.code !== 'BRIDGE_DAEMON_DESCRIPTOR_INVALID') throw error;
333
369
  return { ...UNIDENTIFIED_RUNTIME, state: 'descriptor_invalid' };
334
370
  }
335
371
  if (descriptor === null) return { ...UNIDENTIFIED_RUNTIME, state: 'not_running' };
336
- const body = await attest(descriptor);
372
+ // 旧版daemonはDHCP移動後も設定IPをdescriptorへ残す。
373
+ // serverと共通の同一subnet条件で現在の待受を解決してから本人確認する。
374
+ let config = null;
375
+ try {
376
+ config = await readBridgeConfig({ env });
377
+ } catch (error) {
378
+ if (!['BRIDGE_CONFIG_INVALID', 'BRIDGE_CONFIG_MODE_INVALID'].includes(error?.code)) throw error;
379
+ }
380
+ const body = (await attestKnownBinding(descriptor, config, { interfaces }))?.body ?? null;
337
381
  if (body === null) return { ...UNIDENTIFIED_RUNTIME, state: 'unattested', pid: descriptor.pid };
338
382
  const text = (value) => (typeof value === 'string' ? value : null);
339
383
  return { state: 'running', pid: descriptor.pid, version: text(body.version),
@@ -344,31 +388,42 @@ export async function readBridgeRuntimeIdentity({ env = process.env } = {}) {
344
388
  last_heartbeat: body.last_heartbeat ?? null };
345
389
  }
346
390
 
347
- async function healthy(descriptor, config) {
348
- if (descriptor === null || descriptor.address !== config.listen.address
349
- || descriptor.port !== config.listen.port) return false;
350
- const body = await attest(descriptor);
351
- return body !== null && body.updated_at === config.updated_at;
391
+ async function healthy(descriptor, config, options = {}) {
392
+ const attested = await attestCurrentBinding(descriptor, config, options);
393
+ return attested !== null && attested.body.updated_at === config.updated_at ? attested : null;
352
394
  }
353
395
 
354
- export async function ensureBridgeDaemon({ env = process.env } = {}) {
396
+ async function preserveHealthyDescriptor(descriptor, config, options) {
397
+ const attested = await healthy(descriptor, config, options);
398
+ if (attested === null) return null;
399
+ if (descriptor.address === attested.binding.address && descriptor.port === attested.binding.port) return descriptor;
400
+ return writeBridgeDaemonDescriptor({ config, binding: attested.binding, identity: descriptor, env: options.env });
401
+ }
402
+
403
+ export async function ensureBridgeDaemon({ env = process.env, interfaces = networkInterfaces(),
404
+ spawnDaemon = spawn } = {}) {
355
405
  const config = await readBridgeConfig({ env });
356
406
  if (config === null || !config.enabled) throw new BridgeConfigError('BRIDGE_DISABLED', 'bridge is disabled');
357
407
  const previous = await readBridgeDaemonDescriptor({ env });
358
- if (await healthy(previous, config)) return previous;
359
- if (previous !== null && previous.address === config.listen.address
360
- && previous.port === config.listen.port && await attest(previous) !== null) {
408
+ const options = { env, interfaces };
409
+ const alreadyHealthy = await preserveHealthyDescriptor(previous, config, options);
410
+ if (alreadyHealthy !== null) return alreadyHealthy;
411
+ if (previous !== null && previous.port === config.listen.port
412
+ && await attestKnownBinding(previous, config, options) !== null) {
361
413
  const deadline = Date.now() + START_TIMEOUT_MS;
362
414
  while (Date.now() < deadline) {
363
415
  await new Promise((resolve) => setTimeout(resolve, 50));
364
416
  const reconciled = await readBridgeDaemonDescriptor({ env });
365
- if (reconciled?.pid === previous.pid && await healthy(reconciled, config)) return reconciled;
417
+ if (reconciled?.pid === previous.pid) {
418
+ const healthyDescriptor = await preserveHealthyDescriptor(reconciled, config, options);
419
+ if (healthyDescriptor !== null) return healthyDescriptor;
420
+ }
366
421
  }
367
422
  throw new BridgeConfigError('BRIDGE_DAEMON_UNAVAILABLE',
368
423
  'existing bridge daemon did not reconcile the updated config');
369
424
  }
370
425
  const instanceToken = randomBytes(32).toString('hex');
371
- const child = spawn(process.execPath, [path.resolve(import.meta.dirname, '../bin/lattice-bridge.mjs')], {
426
+ const child = spawnDaemon(process.execPath, [path.resolve(import.meta.dirname, '../bin/lattice-bridge.mjs')], {
372
427
  detached: true, stdio: 'ignore', env: { ...env, LATTICE_BRIDGE_INSTANCE_TOKEN: instanceToken },
373
428
  });
374
429
  child.unref();
@@ -376,22 +431,27 @@ export async function ensureBridgeDaemon({ env = process.env } = {}) {
376
431
  while (Date.now() < deadline) {
377
432
  await new Promise((resolve) => setTimeout(resolve, 50));
378
433
  const descriptor = await readBridgeDaemonDescriptor({ env });
379
- if (await healthy(descriptor, config)) {
434
+ const healthyDescriptor = await preserveHealthyDescriptor(descriptor, config, options);
435
+ if (healthyDescriptor !== null) {
380
436
  if (previous !== null && previous.pid !== descriptor.pid) {
381
- if (await attest(previous) !== null) {
437
+ if (await attestCurrentBinding(previous, config, options) !== null) {
382
438
  try { process.kill(previous.pid, 'SIGTERM'); } catch {}
383
439
  }
384
440
  }
385
- return descriptor;
441
+ return healthyDescriptor;
386
442
  }
387
443
  }
388
444
  throw new BridgeConfigError('BRIDGE_DAEMON_UNAVAILABLE', 'bridge daemon did not bind and become healthy');
389
445
  }
390
446
 
391
- export async function stopBridgeDaemon({ env = process.env } = {}) {
447
+ export async function stopBridgeDaemon({ env = process.env, interfaces = networkInterfaces() } = {}) {
392
448
  const descriptor = await readBridgeDaemonDescriptor({ env });
393
449
  if (descriptor === null) return null;
394
- if (await attest(descriptor) === null) {
450
+ let config = null;
451
+ try { config = await readBridgeConfig({ env }); } catch (error) {
452
+ if (!['BRIDGE_CONFIG_INVALID', 'BRIDGE_CONFIG_MODE_INVALID'].includes(error?.code)) throw error;
453
+ }
454
+ if (await attestKnownBinding(descriptor, config, { interfaces }) === null) {
395
455
  let alive = true;
396
456
  try { process.kill(descriptor.pid, 0); } catch { alive = false; }
397
457
  if (!alive) {
@@ -197,12 +197,12 @@ export function createBridgeHubHeartbeatController({
197
197
  return result;
198
198
  };
199
199
  return Object.freeze({
200
- async tick({ config }) {
200
+ async tick({ config, force = false }) {
201
201
  if (config?.hub == null) {
202
202
  lastSentAt = null; lastResult = null; lastResultAt = null; return null;
203
203
  }
204
204
  const nowMs = now();
205
- if (lastSentAt !== null && nowMs - lastSentAt < intervalMs) return lastResult;
205
+ if (!force && lastSentAt !== null && nowMs - lastSentAt < intervalMs) return lastResult;
206
206
  lastSentAt = nowMs;
207
207
  // 名乗る集合は、この端末が実際に配信している集合そのものを使う。登録簿から別途
208
208
  // 数え直すと、配信している側と名乗る側が別々の基準を持ち、必ずずれる(ADR 0165)。
@@ -217,12 +217,11 @@ function healthHost(address) {
217
217
  return address.includes(':') ? `[${address}]` : address;
218
218
  }
219
219
 
220
- async function defaultWaitReady({ config, instanceToken, env, timeoutMs = START_TIMEOUT_MS }) {
220
+ export async function waitForBridgeLaunchAgentReady({ config, instanceToken, env, timeoutMs = START_TIMEOUT_MS }) {
221
221
  const deadline = Date.now() + timeoutMs;
222
222
  while (Date.now() < deadline) {
223
223
  const descriptor = await readBridgeDaemonDescriptor({ env });
224
- if (descriptor?.address === config.listen.address && descriptor?.port === config.listen.port
225
- && descriptor?.config_updated_at === config.updated_at) {
224
+ if (descriptor?.port === config.listen.port && descriptor?.config_updated_at === config.updated_at) {
226
225
  try {
227
226
  const response = await fetch(
228
227
  `http://${healthHost(descriptor.address)}:${descriptor.port}/__lattice/bridge-health`, {
@@ -231,6 +230,7 @@ async function defaultWaitReady({ config, instanceToken, env, timeoutMs = START_
231
230
  });
232
231
  const body = response.status === 200 ? await response.json() : null;
233
232
  if (body?.schema === 'lattice.bridge_health.v1' && body.pid === descriptor.pid
233
+ && body.address === descriptor.address && body.port === descriptor.port
234
234
  && body.updated_at === config.updated_at) return descriptor;
235
235
  } catch {}
236
236
  }
@@ -321,7 +321,7 @@ async function bootoutIfLoaded({ runner, uid }) {
321
321
  export async function installBridgeLaunchAgent({ config, env = process.env,
322
322
  runner = defaultLaunchctlRunner, uid = userId(), nodePath = process.execPath,
323
323
  bridgePath = DEFAULT_BRIDGE_PATH,
324
- waitReady = defaultWaitReady, waitStopped = defaultWaitStopped,
324
+ waitReady = waitForBridgeLaunchAgentReady, waitStopped = defaultWaitStopped,
325
325
  stableNode = stableNodePath, previousListen = null } = {}) {
326
326
  if (config?.enabled !== true) throw fail('BRIDGE_DISABLED', 'bridge is disabled');
327
327
  const refs = bridgeLaunchAgentPaths(env);
@@ -360,7 +360,7 @@ export async function disableBridgeLaunchAgent({ snapshot, listen, env = process
360
360
 
361
361
  export async function restoreBridgeLaunchAgent({ snapshot, listen = null, env = process.env,
362
362
  runner = defaultLaunchctlRunner, uid = userId(), waitStopped = defaultWaitStopped,
363
- config = undefined, waitReady = defaultWaitReady } = {}) {
363
+ config = undefined, waitReady = waitForBridgeLaunchAgentReady } = {}) {
364
364
  if (!snapshot || typeof snapshot.installed !== 'boolean' || typeof snapshot.loaded !== 'boolean'
365
365
  || (snapshot.installed && typeof snapshot.content !== 'string')) {
366
366
  throw new TypeError('bridge LaunchAgent snapshot required');
@@ -238,7 +238,8 @@ export async function startBridgeServer({
238
238
  // availability probe.
239
239
  response.end(`${JSON.stringify(attested
240
240
  ? { schema: 'lattice.bridge_health.v1', pid: process.pid,
241
- address: currentConfig.listen.address, port: currentConfig.listen.port,
241
+ // 設定IPは公開networkの意図として保ち、healthは実際の待受を返す。
242
+ address: listenAddress, port: actualPort,
242
243
  updated_at: currentConfig.updated_at ?? null,
243
244
  version: packageJson.version, node_path: process.execPath,
244
245
  node_version: process.version, bridge_path: process.argv[1] ?? null,
@@ -198,12 +198,11 @@ function healthHost(address) {
198
198
  return address.includes(':') ? `[${address}]` : address;
199
199
  }
200
200
 
201
- async function defaultWaitReady({ config, instanceToken, env, timeoutMs = START_TIMEOUT_MS }) {
201
+ export async function waitForBridgeStartupReady({ config, instanceToken, env, timeoutMs = START_TIMEOUT_MS }) {
202
202
  const deadline = Date.now() + timeoutMs;
203
203
  while (Date.now() < deadline) {
204
204
  const descriptor = await readBridgeDaemonDescriptor({ env });
205
- if (descriptor?.address === config.listen.address && descriptor?.port === config.listen.port
206
- && descriptor?.config_updated_at === config.updated_at) {
205
+ if (descriptor?.port === config.listen.port && descriptor?.config_updated_at === config.updated_at) {
207
206
  try {
208
207
  const response = await fetch(
209
208
  `http://${healthHost(descriptor.address)}:${descriptor.port}/__lattice/bridge-health`, {
@@ -212,6 +211,7 @@ async function defaultWaitReady({ config, instanceToken, env, timeoutMs = START_
212
211
  });
213
212
  const body = response.status === 200 ? await response.json() : null;
214
213
  if (body?.schema === 'lattice.bridge_health.v1' && body.pid === descriptor.pid
214
+ && body.address === descriptor.address && body.port === descriptor.port
215
215
  && body.updated_at === config.updated_at) return descriptor;
216
216
  } catch {}
217
217
  }
@@ -300,7 +300,7 @@ async function stopRunning({ env, listen, runner, waitStopped }) {
300
300
  export async function installBridgeStartupFolder({ config, env = process.env,
301
301
  runner = defaultStartupRunner, nodePath = process.execPath,
302
302
  bridgePath = DEFAULT_BRIDGE_PATH, supervisorPath = DEFAULT_SUPERVISOR_PATH,
303
- waitReady = defaultWaitReady, waitStopped = defaultWaitStopped,
303
+ waitReady = waitForBridgeStartupReady, waitStopped = defaultWaitStopped,
304
304
  stableNode = stableNodePath, previousListen = null } = {}) {
305
305
  if (config?.enabled !== true) throw fail('BRIDGE_DISABLED', 'bridge is disabled');
306
306
  const refs = bridgeStartupFolderPaths(env);
@@ -354,7 +354,7 @@ export async function disableBridgeStartupFolder({ snapshot, listen, env = proce
354
354
 
355
355
  export async function restoreBridgeStartupFolder({ snapshot, listen = null, env = process.env,
356
356
  runner = defaultStartupRunner, waitStopped = defaultWaitStopped,
357
- config = undefined, waitReady = defaultWaitReady } = {}) {
357
+ config = undefined, waitReady = waitForBridgeStartupReady } = {}) {
358
358
  if (!snapshot || typeof snapshot.installed !== 'boolean'
359
359
  || (snapshot.installed
360
360
  && (typeof snapshot.launcherContent !== 'string' || typeof snapshot.descriptorContent !== 'string'))) {
@@ -25,6 +25,25 @@ import {
25
25
  readTodoIndependenceArtifact, readTodoStore, readTodoWitnessSet,
26
26
  } from './todo-store.mjs';
27
27
 
28
+ export function ownScopeGrowth(taskId, finding) {
29
+ return finding?.kind === 'undeclared_write'
30
+ && Array.isArray(finding.todo_ids)
31
+ && finding.todo_ids.length === 1
32
+ && finding.todo_ids[0] === taskId
33
+ && typeof finding.path === 'string'
34
+ && finding.path.length > 0;
35
+ }
36
+
37
+ export function acceptFindings(taskId, findings) {
38
+ const blocking = [];
39
+ const added = [];
40
+ for (const finding of findings) {
41
+ if (ownScopeGrowth(taskId, finding)) added.push(finding.path);
42
+ else blocking.push(finding);
43
+ }
44
+ return { blocking, added_paths: [...new Set(added)].sort() };
45
+ }
46
+
28
47
  export const PULL_RUN_META_SCHEMA = 'lattice.pull_run_meta.v1';
29
48
  const PULL_EVENT_SCHEMA = 'lattice.pull_run_event.v1';
30
49
  const PULL_EVENTS_FILE = 'pull-events.json';
@@ -1219,15 +1238,16 @@ export async function acceptPullTask({ repoRoot, runDir, taskId, environment = p
1219
1238
  const findings = [...direct, ...independent].filter((finding, index, all) => (
1220
1239
  index === all.findIndex((candidate) => digestArtifact(candidate) === digestArtifact(finding))
1221
1240
  ));
1222
- if (findings.length > 0) {
1223
- const affected = new Set(findings.flatMap((finding) => finding.todo_ids));
1241
+ const { blocking, added_paths: addedPaths } = acceptFindings(taskId, findings);
1242
+ if (blocking.length > 0) {
1243
+ const affected = new Set(blocking.flatMap((finding) => finding.todo_ids));
1224
1244
  for (const affectedTaskId of [...affected].sort()) {
1225
1245
  const affectedIntake = project(current.events, current.meta).intakes
1226
1246
  .find((entry) => entry.task_id === affectedTaskId);
1227
1247
  if (!affectedIntake || affectedIntake.accepted !== null) continue;
1228
1248
  const intervention = { state: 'hold', reason: 'runtime_conflict',
1229
1249
  next_action: 'inspect_findings_and_resolve_or_split', lease_state: 'revoked',
1230
- detail: { findings: findings.filter((finding) => finding.todo_ids.includes(affectedTaskId)) } };
1250
+ detail: { findings: blocking.filter((finding) => finding.todo_ids.includes(affectedTaskId)) } };
1231
1251
  current = await changeIntervention(runDir, current, affectedTaskId, intervention);
1232
1252
  const stoppedIntake = project(current.events, current.meta).intakes
1233
1253
  .find((entry) => entry.task_id === affectedTaskId);
@@ -1240,7 +1260,7 @@ export async function acceptPullTask({ repoRoot, runDir, taskId, environment = p
1240
1260
  }
1241
1261
  }
1242
1262
  }
1243
- fail('RUNTIME_CONFLICT_HOLD', 'observed diffがruntime conflictを生成した', { findings });
1263
+ fail('RUNTIME_CONFLICT_HOLD', 'observed diffがruntime conflictを生成した', { findings: blocking });
1244
1264
  }
1245
1265
  if (intake.intervention.state === 'hold' && intake.intervention.reason === 'runtime_conflict') {
1246
1266
  const released = { state: 'none', reason: null, next_action: null,
@@ -1261,7 +1281,8 @@ export async function acceptPullTask({ repoRoot, runDir, taskId, environment = p
1261
1281
  events: current.events, meta: current.meta, kind: 'task_accepted', taskId,
1262
1282
  payload: { done_event_digest: done.event_digest,
1263
1283
  checkpoint_digest: checkpoint.checkpoint_digest, checkpoint,
1264
- head_sha: checkpoint.diff.head_sha },
1284
+ head_sha: checkpoint.diff.head_sha,
1285
+ ...(addedPaths.length > 0 ? { added_paths: addedPaths } : {}) },
1265
1286
  }));
1266
1287
 
1267
1288
  // 先着taskがacceptedになった後、planning conflictだけで待っていた後着を再投影する。