@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.
- package/bin/lattice-bridge.mjs +5 -2
- package/docs/bridge-setup.md +23 -0
- package/package.json +1 -1
- package/src/bridge-cli.mjs +4 -0
- package/src/bridge-hub-heartbeat.mjs +13 -7
- package/src/bridge-launch-agent.mjs +6 -4
- package/src/bridge-startup-folder.mjs +3 -4
- package/src/todo-dashboard-registry.mjs +28 -0
package/bin/lattice-bridge.mjs
CHANGED
|
@@ -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
|
-
|
|
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',
|
package/docs/bridge-setup.md
CHANGED
|
@@ -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.
|
|
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",
|
package/src/bridge-cli.mjs
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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は「消えた証拠」ではないので
|