@quolu/lattice 0.57.0 → 0.57.2

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.
@@ -140,12 +140,15 @@ timer = setInterval(async () => {
140
140
  // the terminal identity file) reaches this catch.
141
141
  try {
142
142
  const heartbeat = await hubHeartbeat.tick({ config });
143
- hubHeartbeatError = null;
143
+ // 直前に書いた指紋は、健全な状態へ戻った時にだけ捨てる。判定より前に捨てると
144
+ // 比較相手が毎周期nullになり、同じ結果をpoll周期ごとに書き続ける。stderrの
145
+ // 消費者が居ない常駐(WindowsのStartup launcher、LaunchAgent)では、この
146
+ // 書込みが積み上がること自体が常駐を不安定にする。
144
147
  if (['unreachable', 'rejected', 'partial'].includes(heartbeat?.state)) {
145
148
  const fingerprint = JSON.stringify(heartbeat);
146
149
  if (fingerprint !== hubHeartbeatError) process.stderr.write(`${fingerprint}\n`);
147
150
  hubHeartbeatError = fingerprint;
148
- }
151
+ } else hubHeartbeatError = null;
149
152
  } catch (error) {
150
153
  const failure = { schema: 'lattice.bridge_daemon_error.v1',
151
154
  code: error?.code ?? 'BRIDGE_HUB_HEARTBEAT_FAILED',
@@ -54,6 +54,11 @@ bridgeが無効な間は以下すべてnullで、bridgeを有効にしている
54
54
  | `node_path`・`node_exists` | 起動するNode実行体と、それが今も存在するか |
55
55
  | `bridge_path`・`bridge_exists` | 起動するbridge scriptと、それが今も存在するか |
56
56
  | `error` | `unreadable`のときだけtyped code(例`BRIDGE_LAUNCH_AGENT_PLIST_UNSAFE`) |
57
+ | `error=BRIDGE_PERSISTENCE_STATE_SPLIT` | launcherまたはplistの片割れだけが残った状態。手でfileを消さず、`lattice bridge reconfigure --json`で常駐設定を揃える(0.57.2以降) |
58
+
59
+ `BRIDGE_PERSISTENCE_STATE_SPLIT`は、常駐設定の一方(macOSのplist、またはWindowsのlauncher/descriptor)だけが
60
+ 存在することを示す。これは復旧対象そのものが壊れている状態なので、手作業でfileを削除せず、
61
+ `lattice bridge reconfigure --json`を実行して正規のinstall経路で両方を再生成する。
57
62
 
58
63
  **`runtime`** — いま応答しているprocess自身の申告。`state`は`running`/`not_running`/`unattested`/
59
64
  `descriptor_invalid`で、`running`以外では各値がnullになる。`running`でも、identityを返さない
@@ -98,6 +103,24 @@ bridgeが無効な間は以下すべてnullで、bridgeを有効にしている
98
103
  lattice bridge reconfigure --json
99
104
  ```
100
105
 
106
+ ### 公開面から自分のprojectが消えた時(`last_heartbeat`)
107
+
108
+ hubへ繋いでいる端末では、`runtime.last_heartbeat`が最後にhubへ名乗った結果を持つ。公開一覧で
109
+ 自分のprojectがofflineになっている時、原因が端末側か配線側かはここで分かれる。
110
+
111
+ | `state` | 意味 | 打つ手 |
112
+ | --- | --- | --- |
113
+ | `accepted` | 全件受理された。公開面に出ていないならhub側の可視性設定を見る | — |
114
+ | `partial` | 一部が他の生きた端末に所有されている。`rejected_projects`が名指しする | 意図した端末なら放置。奪うなら`adopt` |
115
+ | `rejected` | hubがrequest全体を拒否した。`detail`にtyped code | detailのcodeで分岐 |
116
+ | `unreachable` | hubへ届かない。配線かhubの停止 | hubのURLと生死を確認 |
117
+ | `skipped_no_projects` | 配信しているprojectが0件。名乗るものが無い | 正常な静止。公開したいならそのrepoで作業する |
118
+ | `skipped_no_dashboard` | dashboard daemonを観測できない。配信そのものが立っていない | daemonの生死を見る。`todo`系commandを1回打てば起動する |
119
+ | `null` | hub未設定、またはまだ1回も送っていない | — |
120
+
121
+ 名乗る集合はdaemonが実際に配信している集合そのもの(ADR 0165)。登録簿の`last_seen_at`が古い
122
+ ことは露出に影響しない——**人がCLIを叩かなくなっただけで公開面から消えることはない**。
123
+
101
124
  ### 焼き込むnode pathの選び方(ADR 0163)
102
125
 
103
126
  常駐設定へ焼く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.57.0",
3
+ "version": "0.57.2",
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",
@@ -112,6 +112,10 @@ async function bridgePersistence({ launchAgent, env }) {
112
112
  // `loaded` is launchd-specific; the Windows Startup folder has no such
113
113
  // concept and reports null rather than pretending to know.
114
114
  const loaded = typeof snapshot?.loaded === 'boolean' ? snapshot.loaded : null;
115
+ if (snapshot?.split === true) {
116
+ return { state: 'unreadable', ...UNREADABLE_PERSISTENCE, loaded,
117
+ error: 'BRIDGE_PERSISTENCE_STATE_SPLIT' };
118
+ }
115
119
  if (described === null) return { state: 'not_installed', ...UNREADABLE_PERSISTENCE, loaded, error: null };
116
120
  return { state: 'installed', loaded, ...described, error: null };
117
121
  } catch (error) {
@@ -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
 
@@ -180,7 +181,7 @@ export function bridgeHubHeartbeatSummary(result, at = null) {
180
181
  export function createBridgeHubHeartbeatController({
181
182
  env = process.env, fetchImpl = fetch, now = () => Date.now(),
182
183
  intervalMs = BRIDGE_HUB_HEARTBEAT_INTERVAL_MS,
183
- readActiveProjects = readActiveTodoDashboardProjects,
184
+ readServedProjectIds = readServedTodoDashboardProjectIds,
184
185
  } = {}) {
185
186
  let lastSentAt = null;
186
187
  let lastResult = null;
@@ -198,13 +199,18 @@ export function createBridgeHubHeartbeatController({
198
199
  const nowMs = now();
199
200
  if (lastSentAt !== null && nowMs - lastSentAt < intervalMs) return lastResult;
200
201
  lastSentAt = nowMs;
201
- const projects = await readActiveProjects({ env });
202
- if (projects.length === 0) {
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) {
203
209
  return record({ schema: HEARTBEAT_RESULT_SCHEMA, state: 'skipped_no_projects' }, nowMs);
204
210
  }
205
211
  const terminalId = await readOrCreateBridgeHubTerminalId({ env });
206
212
  const request = buildBridgeHubRegistrationRequest({
207
- terminalId, port: config.listen.port, projectIds: projects.map((project) => project.project_id),
213
+ terminalId, port: config.listen.port, projectIds,
208
214
  });
209
215
  return record(
210
216
  await sendBridgeHubHeartbeat({ hubUrl: config.hub.url, request, fetchImpl }), nowMs,
@@ -247,10 +247,8 @@ export async function snapshotBridgeLaunchAgent({ env = process.env,
247
247
  await prepareDirectory(refs.directory, uid);
248
248
  const content = await strictPlist(refs.plist, uid);
249
249
  const loaded = await loadedState(runner, uid);
250
- if (loaded && content === null) {
251
- throw fail('BRIDGE_LAUNCH_AGENT_STATE_INVALID', 'loaded bridge LaunchAgent has no owned plist');
252
- }
253
- return Object.freeze({ installed: content !== null, loaded, content });
250
+ const split = loaded && content === null;
251
+ return Object.freeze({ installed: content !== null, loaded, split, content });
254
252
  }
255
253
 
256
254
  const PROGRAM_ARGUMENTS_PATTERN =
@@ -346,6 +344,10 @@ export async function restoreBridgeLaunchAgent({ snapshot, listen = null, env =
346
344
  await prepareDirectory(refs.directory, uid);
347
345
  const stopped = await bootoutIfLoaded({ runner, uid });
348
346
  if (stopped) await waitStopped({ listen, env });
347
+ if (snapshot.split === true) {
348
+ await rm(refs.plist, { force: true });
349
+ return snapshot;
350
+ }
349
351
  if (snapshot.installed) await atomicPlist(refs.plist, snapshot.content);
350
352
  else await rm(refs.plist, { force: true });
351
353
  if (snapshot.loaded) {
@@ -239,10 +239,9 @@ export async function snapshotBridgeStartupFolder({ env = process.env } = {}) {
239
239
  await prepareDirectory(refs.runtimeDirectory);
240
240
  const launcherContent = await strictFile(refs.launcher);
241
241
  const descriptorContent = await strictFile(refs.descriptor);
242
- if ((launcherContent === null) !== (descriptorContent === null)) {
243
- throw fail('BRIDGE_STARTUP_FOLDER_STATE_INVALID', 'startup launcher and descriptor disagree on installed state');
244
- }
245
- return Object.freeze({ installed: launcherContent !== null, launcherContent, descriptorContent });
242
+ const split = (launcherContent === null) !== (descriptorContent === null);
243
+ return Object.freeze({ installed: launcherContent !== null && descriptorContent !== null,
244
+ split, launcherContent, descriptorContent });
246
245
  }
247
246
 
248
247
  async function pathPresent(ref) {
@@ -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は「消えた証拠」ではないので