@quolu/lattice 0.57.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.
@@ -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.57.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",
@@ -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,
@@ -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は「消えた証拠」ではないので