@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.
- package/bin/lattice-bridge.mjs +3 -2
- package/package.json +1 -1
- package/src/bridge-cli.mjs +14 -2
- package/src/bridge-daemon.mjs +5 -2
- package/src/bridge-hub-heartbeat.mjs +53 -7
- package/src/bridge-hub-protocol.mjs +47 -25
- package/src/bridge-hub-server.mjs +6 -3
- package/src/bridge-server.mjs +8 -2
- package/src/cli-help.mjs +2 -0
- package/src/runtime-cli.mjs +10 -0
- package/src/runtime-pull-intake.mjs +96 -3
- package/src/todo-cli.mjs +50 -15
package/bin/lattice-bridge.mjs
CHANGED
|
@@ -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 (
|
|
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.
|
|
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",
|
package/src/bridge-cli.mjs
CHANGED
|
@@ -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) {
|
package/src/bridge-daemon.mjs
CHANGED
|
@@ -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 —
|
|
54
|
-
*
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
145
|
-
const
|
|
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
|
-
|
|
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(
|
|
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
|
|
174
|
-
*
|
|
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({
|
|
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
|
-
|
|
446
|
-
|
|
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
|
}
|
package/src/bridge-server.mjs
CHANGED
|
@@ -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',
|
package/src/runtime-cli.mjs
CHANGED
|
@@ -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: '
|
|
830
|
+
next_action: 'lattice run intake detach --run <run> --task <task> --json',
|
|
809
831
|
});
|
|
810
832
|
}
|
|
811
|
-
|
|
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
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
// `
|
|
3144
|
-
//
|
|
3145
|
-
|
|
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
|