@quolu/lattice 0.63.2 → 0.63.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.63.2",
3
+ "version": "0.63.4",
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",
@@ -319,6 +319,15 @@ async function replaceCanonical({ pathname, expectedBytes, value, validator, cra
319
319
  }
320
320
  }
321
321
 
322
+ /**
323
+ * control-events.jsonを書く他module(gate storeのatomic commit)が、このstoreのappendと
324
+ * 同じper-directory直列化を共有するための入口。同一process内で書き手が2つの規律に
325
+ * 分かれると、appendがcommitの読みと置換の間へ挟まり偽のcommitted prefix衝突になる。
326
+ */
327
+ export function withControlJournalMutation(runDir, mutation) {
328
+ return enqueue(path.resolve(runDir), mutation);
329
+ }
330
+
322
331
  function enqueue(runDir, mutation) {
323
332
  const previous = mutationQueues.get(runDir) ?? Promise.resolve();
324
333
  const result = previous.then(mutation);
@@ -1,14 +1,12 @@
1
- import { execFile } from 'node:child_process';
2
1
  import { lstat, realpath } from 'node:fs/promises';
3
2
  import path from 'node:path';
4
- import { promisify } from 'node:util';
5
3
 
6
4
  import { digestArtifact } from './artifact-contracts.mjs';
7
5
  import { selfDigest } from './runtime-contracts.mjs';
8
6
  import { validateProcessStartIdentity } from './runtime-controller-protocol.mjs';
9
7
  import { captureWorktreeDiff } from './runtime-diff-observer.mjs';
8
+ import { observePsSnapshot } from './runtime-os-observation.mjs';
10
9
 
11
- const execFileAsync = promisify(execFile);
12
10
  const GIT_SHA1 = /^[0-9a-f]{40}$/;
13
11
  const SHA256 = /^[0-9a-f]{64}$/;
14
12
 
@@ -70,10 +68,7 @@ function validateResolvedBinding(value) {
70
68
 
71
69
  async function runPsSnapshot() {
72
70
  try {
73
- const { stdout } = await execFileAsync('/bin/ps', [
74
- '-axo', 'pid=,ppid=,pgid=,state=,lstart=',
75
- ], { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
76
- return stdout;
71
+ return await observePsSnapshot();
77
72
  } catch (error) {
78
73
  fail(`ps観測失敗: ${error?.code ?? error?.message ?? 'unknown'}`);
79
74
  }
@@ -4,6 +4,7 @@ import { lstat, mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
 
6
6
  import { canonicalizeArtifact } from './artifact-contracts.mjs';
7
+ import { withControlJournalMutation } from './runtime-control-store.mjs';
7
8
  import { selfDigest } from './runtime-contracts.mjs';
8
9
 
9
10
  const SHA256 = /^[0-9a-f]{64}$/;
@@ -407,9 +408,17 @@ async function commitBuilt({ runDir, built, crashInjector, controlEventsPath = n
407
408
  }
408
409
 
409
410
  export async function commitRuntimeGateActivation(options) {
410
- const built = await buildCommit(options);
411
- return commitBuilt({ runDir: options.runDir, built, crashInjector: options.crashInjector,
412
- controlEventsPath: options.controlEventsPath ?? null });
411
+ // control journalの読み(buildCommit)と置換(commitBuilt)の間へ、同一process内の
412
+ // control store appendが挟まると、committed prefix検査が偽のGATE_COMMIT_CONFLICTに
413
+ // なる(2026-08-22 CI負荷下で実被弾: managed recompileのepoch activationへdaemonの
414
+ // 並行appendが割り込んだ)。appendと同じper-directory直列化を共有して排除する。
415
+ // 別process間の競合検査は従来どおりprefix比較が持つ。
416
+ const eventsPath = options.controlEventsPath ?? path.join(options.runDir, 'control-events.json');
417
+ return withControlJournalMutation(path.dirname(eventsPath), async () => {
418
+ const built = await buildCommit(options);
419
+ return commitBuilt({ runDir: options.runDir, built, crashInjector: options.crashInjector,
420
+ controlEventsPath: options.controlEventsPath ?? null });
421
+ });
413
422
  }
414
423
 
415
424
  export async function recoverRuntimeGateCommit(options) {
@@ -10,6 +10,7 @@ import { selfDigest } from './runtime-contracts.mjs';
10
10
  import { captureWorktreeDiff } from './runtime-diff-observer.mjs';
11
11
  import { pidsOwningSocketPath, socketPathsOwnedByPid } from './runtime-socket-owner.mjs';
12
12
  import { createDirectOsProcessObserver as createDirectOsProcessObserverV2 } from './runtime-direct-os-observer.mjs';
13
+ import { observeExecutablePath, observeStartIdentityRaw, osObservationEnvironment } from './runtime-os-observation.mjs';
13
14
  import {
14
15
  armStagedWriteLease,
15
16
  createControllerBootstrap,
@@ -59,14 +60,11 @@ export async function observeManagedProcessStartIdentity(pid) {
59
60
  }
60
61
  let startedIdentity;
61
62
  try {
62
- const executable = process.platform === 'win32' ? 'powershell.exe' : '/bin/ps';
63
- const args = process.platform === 'win32'
64
- ? ['-NoProfile', '-NonInteractive', '-Command',
65
- `[System.Diagnostics.Process]::GetProcessById(${pid}).StartTime.ToUniversalTime().Ticks`]
66
- : ['-o', 'lstart=', '-p', String(pid)];
67
- const { stdout } = await execFileAsync(executable, args, { encoding: 'utf8' });
68
- startedIdentity = stdout.trim();
69
- } catch { fail('ADAPTER_CONTROLLER_UNAVAILABLE', `process start identity観測失敗: ${pid}`); }
63
+ startedIdentity = await observeStartIdentityRaw(pid);
64
+ } catch (error) {
65
+ const cause = String(error?.stderr ?? '').trim() || error?.message || 'unknown';
66
+ fail('ADAPTER_CONTROLLER_UNAVAILABLE', `process start identity観測失敗: pid=${pid}, 観測コマンドの失敗=${cause}`);
67
+ }
70
68
  if (!startedIdentity) fail('ADAPTER_CONTROLLER_UNAVAILABLE', `process不在: ${pid}`);
71
69
  const identity = { schema: 'lattice.process_start_identity.v1', platform: process.platform, pid, started_identity: startedIdentity, identity_digest: '' };
72
70
  identity.identity_digest = selfDigest(identity, 'identity_digest');
@@ -109,18 +107,10 @@ export async function observeProcessExecutablePath(pid) {
109
107
  if (!Number.isSafeInteger(pid) || pid < 1) {
110
108
  fail('ADAPTER_BINARY_IDENTITY_MISMATCH', 'exec後image PID不正');
111
109
  }
112
- if (process.platform === 'linux') {
113
- return realpath(`/proc/${pid}/exe`);
114
- }
115
- if (process.platform === 'darwin') {
116
- const { stdout } = await execFileAsync(
117
- '/bin/ps',
118
- ['-o', 'comm=', '-p', String(pid)],
119
- { encoding: 'utf8' },
120
- );
121
- return realpath(stdout.trim());
110
+ if (process.platform !== 'linux' && process.platform !== 'darwin') {
111
+ fail('ADAPTER_BINARY_IDENTITY_MISMATCH', `exec後image path観測未対応platform: ${process.platform}`);
122
112
  }
123
- fail('ADAPTER_BINARY_IDENTITY_MISMATCH', `exec後image path観測未対応platform: ${process.platform}`);
113
+ return observeExecutablePath(pid);
124
114
  }
125
115
 
126
116
  /** storeが解決したimmutable bindingからprocess/worktree/checkpointをDirect OSで再観測する。 */
@@ -170,7 +160,7 @@ export function createDirectOsProcessObserver({ resolveObservationBinding }) {
170
160
  let observedIdentity = resolved.process_start_identity;
171
161
  let processGroupId = resolved.process_group_id;
172
162
  try {
173
- const { stdout } = await execFileAsync('/bin/ps', ['-o', 'lstart=,pgid=,state=', '-p', String(resolved.process_pid)], { encoding: 'utf8' });
163
+ const { stdout } = await execFileAsync('/bin/ps', ['-o', 'lstart=,pgid=,state=', '-p', String(resolved.process_pid)], { encoding: 'utf8', env: osObservationEnvironment() });
174
164
  const line = stdout.trim();
175
165
  if (line) {
176
166
  const match = line.match(/^(.*\d{4})\s+(\d+)\s+(\S+)$/);
@@ -188,7 +178,7 @@ export function createDirectOsProcessObserver({ resolveObservationBinding }) {
188
178
  }
189
179
  if (!['stopped', 'exited'].includes(processState)) fail('HOLD_ACKS_INCOMPLETE', 'executor processがquiescedでない');
190
180
  try {
191
- const { stdout: groupStdout } = await execFileAsync('/bin/ps', ['-o', 'pid=,state=', '-g', String(resolved.process_group_id)], { encoding: 'utf8' });
181
+ const { stdout: groupStdout } = await execFileAsync('/bin/ps', ['-o', 'pid=,state=', '-g', String(resolved.process_group_id)], { encoding: 'utf8', env: osObservationEnvironment() });
192
182
  for (const line of groupStdout.trim().split('\n').filter(Boolean)) {
193
183
  const match = line.trim().match(/^(\d+)\s+(\S+)$/);
194
184
  if (!match || (!match[2].startsWith('T') && !match[2].startsWith('Z'))) fail('HOLD_ACKS_INCOMPLETE', 'process group childがquiescedでない');
@@ -1011,8 +1001,33 @@ async function activateManagedSupervisorController({ repoRoot, runDir, runId, ad
1011
1001
  fail('ADAPTER_LAUNCH_INVALID', 'existing endpoint ownerが一意でない');
1012
1002
  }
1013
1003
  }
1014
- const handshake = await exchangeControllerHandshake({ socketPath: handshakeConnectPath, runId,
1015
- supervisorSessionNonce, controllerSocketRef, timeoutMs });
1004
+ // socket fileの出現はbindの証拠でありlistenの証拠ではない。負荷下ではcontrollerが
1005
+ // bindとlistenの間でpreemptされ、fileを見て接続した一回だけECONNREFUSEDになる
1006
+ // (2026-08-22 CI負荷下で実被弾)。起動したchildに限り、listen受理までを起動待ちに
1007
+ // 含めてdeadline内はECONNREFUSEDだけを待ち直す。死んだcontrollerはexitCode検査が、
1008
+ // 起動しないcontrollerはdeadlineが従来どおり落とす。既存endpoint(child無し)は
1009
+ // owner検証済みのlistener前提なので単発のまま。
1010
+ const handshakeDeadline = Date.now() + timeoutMs;
1011
+ let handshake;
1012
+ for (;;) {
1013
+ try {
1014
+ handshake = await exchangeControllerHandshake({ socketPath: handshakeConnectPath, runId,
1015
+ supervisorSessionNonce, controllerSocketRef, timeoutMs });
1016
+ break;
1017
+ } catch (error) {
1018
+ const listenPending = child !== null && error instanceof ManagedRuntimeError
1019
+ && ['ECONNREFUSED', 'ENOENT'].some((code) => String(error.message).includes(code))
1020
+ && Date.now() < handshakeDeadline;
1021
+ if (!listenPending) throw error;
1022
+ if (child.exitCode !== null) {
1023
+ fail(
1024
+ 'ADAPTER_CONTROLLER_UNAVAILABLE',
1025
+ `controller exited: ${child.exitCode}${childStderr ? `: ${childStderr.trim()}` : ''}`,
1026
+ );
1027
+ }
1028
+ await new Promise((resolve) => setTimeout(resolve, 20));
1029
+ }
1030
+ }
1016
1031
  const controllerDescriptor = handshake.descriptor;
1017
1032
  if (controllerDescriptor.adapter_kind !== adapterKind || controllerDescriptor.socket_ref !== controllerSocketRef
1018
1033
  || controllerDescriptor.capabilities.capabilities_digest !== launch.capabilities_digest
@@ -490,18 +490,11 @@ export async function activateEpochOneStore({
490
490
  }
491
491
  }
492
492
 
493
- const meta = {
494
- schema: 'lattice.run_meta.v2',
495
- run_id: normalizedMeta.run_id,
496
- executor_adapter: normalizedMeta.executor_adapter,
497
- run_event_schema: 'lattice.run_event.v1',
498
- control_event_schema: 'lattice.runtime_control_event.v1',
499
- epoch_bundle_schema: 'lattice.runtime_epoch_bundle.v1',
500
- created_plan_digest: compileArtifact.plan.plan_digest,
501
- };
502
- meta.meta_digest = digestArtifact(meta);
503
- await replaceDurableJson(runDir, 'run-meta.json', meta);
504
-
493
+ // pointerをmetaより先に書く。読み手はmeta v2を見てからpointerを読むので、逆順だと
494
+ // 2書き込みの間にmeta v2だけが見え、並行するreaderがINVALID_RUN_STORE
495
+ // (committed epoch pointerを読めない)で落ちる(2026-08-22 CI負荷下で実被弾)。
496
+ // この順ならmeta v2の可視がpointer実在を含意し、途中crashはmeta v1のまま
497
+ // (余剰のpointerはlegacy読取りに影響せず、再activationが置換する)。
505
498
  const pointer = {
506
499
  schema: 'lattice.committed_epoch_pointer.v1',
507
500
  run_id: normalizedMeta.run_id,
@@ -513,6 +506,18 @@ export async function activateEpochOneStore({
513
506
  };
514
507
  pointer.pointer_digest = digestArtifact(pointer);
515
508
  await replaceDurableJson(runDir, 'committed-epoch.json', pointer);
509
+
510
+ const meta = {
511
+ schema: 'lattice.run_meta.v2',
512
+ run_id: normalizedMeta.run_id,
513
+ executor_adapter: normalizedMeta.executor_adapter,
514
+ run_event_schema: 'lattice.run_event.v1',
515
+ control_event_schema: 'lattice.runtime_control_event.v1',
516
+ epoch_bundle_schema: 'lattice.runtime_epoch_bundle.v1',
517
+ created_plan_digest: compileArtifact.plan.plan_digest,
518
+ };
519
+ meta.meta_digest = digestArtifact(meta);
520
+ await replaceDurableJson(runDir, 'run-meta.json', meta);
516
521
  return { bundle, meta, pointer };
517
522
  }
518
523
 
@@ -0,0 +1,58 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { realpath } from 'node:fs/promises';
3
+ import { promisify } from 'node:util';
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ // OS観測はobserverのlocaleへ依存させない。psのlstart書式(LC_TIME)と非ASCII argvの
8
+ // エスケープ有無(LC_CTYPE)はlocaleで変わり、同じprocessでも観測者ごとにidentity digestが
9
+ // 割れる(2026-08-22 実測: 席fileと席自身の観測でWORKER_IDENTITY_MISMATCH)。
10
+ //
11
+ // 既知の限界: Linuxの`LC_ALL=C`は非ASCIIバイトを`?`へ非可逆に潰すため、非ASCII部分だけが
12
+ // 違うargvの識別力はLinuxでは落ちる(観測者間の一致は保たれる——潰れた`?`は誰が観測しても
13
+ // 同じ`?`になるので、locale不一致によるdigest分裂は防げる)。darwinの`ps`はvisエスケープで
14
+ // 非ASCIIを可逆に表現するため、この劣化は起きない。
15
+ export function osObservationEnvironment() { return { ...process.env, LC_ALL: 'C' }; }
16
+
17
+ /** processのstart identity(darwin/linuxはps lstart、win32はPowerShell StartTime Ticks)の生文字列を返す。 */
18
+ export async function observeStartIdentityRaw(pid) {
19
+ const executable = process.platform === 'win32' ? 'powershell.exe' : '/bin/ps';
20
+ const args = process.platform === 'win32'
21
+ ? ['-NoProfile', '-NonInteractive', '-Command',
22
+ `[System.Diagnostics.Process]::GetProcessById(${pid}).StartTime.ToUniversalTime().Ticks`]
23
+ : ['-o', 'lstart=', '-p', String(pid)];
24
+ const { stdout } = await execFileAsync(executable, args, { encoding: 'utf8', env: osObservationEnvironment() });
25
+ return stdout.trim();
26
+ }
27
+
28
+ /** processのargv(`ps -o command=`)の生文字列を返す。 */
29
+ export async function observeArgv(pid) {
30
+ const { stdout } = await execFileAsync('/bin/ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8', env: osObservationEnvironment() });
31
+ return stdout.trim();
32
+ }
33
+
34
+ /** processのprocess group id(`ps -o pgid=`)の生文字列を返す。 */
35
+ export async function observePgid(pid) {
36
+ const { stdout } = await execFileAsync('/bin/ps', ['-o', 'pgid=', '-p', String(pid)], { encoding: 'utf8', env: osObservationEnvironment() });
37
+ return stdout.trim();
38
+ }
39
+
40
+ /** 全processの`pid=,ppid=,pgid=,state=,lstart=`スナップショットの生stdoutを返す。 */
41
+ export async function observePsSnapshot() {
42
+ const { stdout } = await execFileAsync('/bin/ps', [
43
+ '-axo', 'pid=,ppid=,pgid=,state=,lstart=',
44
+ ], { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, env: osObservationEnvironment() });
45
+ return stdout;
46
+ }
47
+
48
+ /**
49
+ * 実行中processの実行imageのcanonical pathを観測する。linuxは`/proc/<pid>/exe`、
50
+ * darwinは`ps -o comm=`の結果をrealpathで正規化する。
51
+ */
52
+ export async function observeExecutablePath(pid) {
53
+ if (process.platform === 'linux') {
54
+ return realpath(`/proc/${pid}/exe`);
55
+ }
56
+ const { stdout } = await execFileAsync('/bin/ps', ['-o', 'comm=', '-p', String(pid)], { encoding: 'utf8', env: osObservationEnvironment() });
57
+ return realpath(stdout.trim());
58
+ }
@@ -1,11 +1,10 @@
1
- import { execFile, spawn } from 'node:child_process';
1
+ import { spawn } from 'node:child_process';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
3
  import { constants as fsConstants } from 'node:fs';
4
4
  import {
5
5
  lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, writeFile,
6
6
  } from 'node:fs/promises';
7
7
  import path from 'node:path';
8
- import { promisify } from 'node:util';
9
8
 
10
9
  import { canonicalizeArtifact, digestArtifact } from './artifact-contracts.mjs';
11
10
  import { validateExpectedWorkerProcess } from './runtime-controller-protocol.mjs';
@@ -13,6 +12,7 @@ import { classifyObservedDiff } from './runtime-decision-verifier.mjs';
13
12
  import { captureWorktreeDiff, detectCheckpointFindings } from './runtime-diff-observer.mjs';
14
13
  import { acquireRuntimeLifecycleLock } from './runtime-lifecycle-lock.mjs';
15
14
  import { observeManagedProcessStartIdentity } from './runtime-managed-supervisor.mjs';
15
+ import { observeArgv, observePgid } from './runtime-os-observation.mjs';
16
16
  import { deliverWorkerSignal, observeWindowsWorkerProcess } from './runtime-windows-process.mjs';
17
17
  import { ensureScriptedWorktree } from './runtime-scripted-worktree.mjs';
18
18
  import {
@@ -33,7 +33,6 @@ const SHA1 = /^[0-9a-f]{40}$/u;
33
33
  const SHA256 = /^[0-9a-f]{64}$/u;
34
34
  const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
35
35
  const MAX_FILE_BYTES = 8_388_608;
36
- const execFileAsync = promisify(execFile);
37
36
 
38
37
  export class PullRunError extends Error {
39
38
  constructor(code, message, detail) {
@@ -755,17 +754,19 @@ export async function intakePullTask({ repoRoot, runDir, taskId, environment = p
755
754
  const updated = project(current.events, current.meta).intakes
756
755
  .find((entry) => entry.task_id === taskId);
757
756
  if (updated.worker && intervention.state === 'hold' && !updated.worker.stopped) {
758
- await signalAttachedWorker(updated, 'SIGSTOP');
759
- current = await appendEvent(runDir, current, buildEvent({
760
- events: current.events, meta: current.meta, kind: 'worker_stopped', taskId,
761
- payload: { reason: intervention.reason },
762
- }));
757
+ if (await signalAttachedWorker(updated, 'SIGSTOP')) {
758
+ current = await appendEvent(runDir, current, buildEvent({
759
+ events: current.events, meta: current.meta, kind: 'worker_stopped', taskId,
760
+ payload: { reason: intervention.reason },
761
+ }));
762
+ }
763
763
  } else if (updated.worker?.stopped && intervention.state === 'none') {
764
- await signalAttachedWorker(updated, 'SIGCONT');
765
- current = await appendEvent(runDir, current, buildEvent({
766
- events: current.events, meta: current.meta, kind: 'worker_resumed', taskId,
767
- payload: { released_by: 'intake_refresh' },
768
- }));
764
+ if (await signalAttachedWorker(updated, 'SIGCONT')) {
765
+ current = await appendEvent(runDir, current, buildEvent({
766
+ events: current.events, meta: current.meta, kind: 'worker_resumed', taskId,
767
+ payload: { released_by: 'intake_refresh' },
768
+ }));
769
+ }
769
770
  }
770
771
  state = project(current.events, current.meta);
771
772
  const result = { schema: 'lattice.pull_intake_result.v1', outcome: 'intaked',
@@ -922,10 +923,10 @@ async function observeProcessBinding(pid) {
922
923
  };
923
924
  }
924
925
  const processStartIdentity = await observeManagedProcessStartIdentity(pid);
925
- const { stdout: pgidOut } = await execFileAsync('/bin/ps', ['-o', 'pgid=', '-p', String(pid)], { encoding: 'utf8' });
926
- const { stdout: argvOut } = await execFileAsync('/bin/ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8' });
927
- const processGroupId = Number(pgidOut.trim());
928
- const argv = argvOut.trim();
926
+ const pgidOut = await observePgid(pid);
927
+ const argvOut = await observeArgv(pid);
928
+ const processGroupId = Number(pgidOut);
929
+ const argv = argvOut;
929
930
  const expected = { pid, process_group_id: processGroupId, process_start_identity: processStartIdentity };
930
931
  if (!validateExpectedWorkerProcess(expected) || argv.length === 0) {
931
932
  fail('WORKER_IDENTITY_MISMATCH', 'worker process identityを完全観測できない');
@@ -937,16 +938,26 @@ async function observeProcessBinding(pid) {
937
938
  };
938
939
  }
939
940
 
941
+ // signal前の再認証は不変量(pid + lstart)だけで行う。argvはTUIがプロセスタイトルを
942
+ // 書き換えると変わり、pgidもprocess自身が変えられる——可変量を照合に使うと、生きている
943
+ // 正当なworkerへsignalできなくなり、detach/release/holdの全経路が恒久停止する。
944
+ // pid + lstart が同じなら同一process instanceであり、誤配は起きない。
945
+ // 不在(ESRCH)とpid再利用(lstart不一致)は「配達先がもう居ない」=falseで返し、
946
+ // 観測自体の失敗だけを失敗として扱う。
940
947
  async function signalAttachedWorker(intake, signal) {
941
948
  if (intake.worker === null) return false;
949
+ try { process.kill(intake.worker.pid, 0); }
950
+ catch (error) { if (error?.code === 'ESRCH') return false; }
942
951
  let observed;
943
952
  try { observed = await observeProcessBinding(intake.worker.pid); }
944
- catch { fail('WORKER_IDENTITY_MISMATCH', 'signal前にworkerを再認証できない'); }
953
+ catch {
954
+ try { process.kill(intake.worker.pid, 0); }
955
+ catch (error) { if (error?.code === 'ESRCH') return false; }
956
+ fail('WORKER_IDENTITY_MISMATCH', 'signal前にworkerを再認証できない');
957
+ }
945
958
  if (observed.process_start_identity.identity_digest
946
- !== intake.worker.process_start_identity.identity_digest
947
- || observed.argv_digest !== intake.worker.argv_digest
948
- || observed.process_group_id !== intake.worker.process_group_id) {
949
- fail('WORKER_IDENTITY_MISMATCH', 'signal前のlstart/argv/pgidがattach bindingと一致しない');
959
+ !== intake.worker.process_start_identity.identity_digest) {
960
+ return false;
950
961
  }
951
962
  try { deliverWorkerSignal(intake.worker.pid, signal); }
952
963
  catch { fail('WORKER_SIGNAL_FAILED', `workerへ${signal}を送れない`); }
@@ -994,8 +1005,10 @@ export async function attachPullWorker({ runDir, taskId, input, environment = pr
994
1005
  fail('WORKER_ALREADY_ATTACHED', `同じworkerは複数active intakeへattachできない: ${occupied.task_id}`);
995
1006
  }
996
1007
  if (occupied && occupied.intervention.state === 'hold') {
997
- if (occupied.worker.stopped) {
998
- await signalAttachedWorker(occupied, 'SIGCONT');
1008
+ const resumed = occupied.worker.stopped
1009
+ ? await signalAttachedWorker(occupied, 'SIGCONT')
1010
+ : false;
1011
+ if (resumed) {
999
1012
  current = await appendEvent(runDir, current, buildEvent({
1000
1013
  events: current.events, meta: current.meta, kind: 'worker_resumed',
1001
1014
  taskId: occupied.task_id, payload: { released_by: 'stale_hold_reattach' },
@@ -1004,7 +1017,7 @@ export async function attachPullWorker({ runDir, taskId, input, environment = pr
1004
1017
  current = await appendEvent(runDir, current, buildEvent({
1005
1018
  events: current.events, meta: current.meta, kind: 'worker_detached',
1006
1019
  taskId: occupied.task_id,
1007
- payload: { detached_by: actor, pid: occupied.worker.pid, resumed: occupied.worker.stopped },
1020
+ payload: { detached_by: actor, pid: occupied.worker.pid, resumed },
1008
1021
  }));
1009
1022
  state = project(current.events, current.meta);
1010
1023
  }
@@ -1023,11 +1036,12 @@ export async function attachPullWorker({ runDir, taskId, input, environment = pr
1023
1036
  }
1024
1037
  const refreshed = state.intakes.find((entry) => entry.task_id === taskId);
1025
1038
  if (refreshed.intervention.state === 'hold' && !refreshed.worker.stopped) {
1026
- await signalAttachedWorker(refreshed, 'SIGSTOP');
1027
- current = await appendEvent(runDir, current, buildEvent({
1028
- events: current.events, meta: current.meta, kind: 'worker_stopped', taskId,
1029
- payload: { reason: refreshed.intervention.reason },
1030
- }));
1039
+ if (await signalAttachedWorker(refreshed, 'SIGSTOP')) {
1040
+ current = await appendEvent(runDir, current, buildEvent({
1041
+ events: current.events, meta: current.meta, kind: 'worker_stopped', taskId,
1042
+ payload: { reason: refreshed.intervention.reason },
1043
+ }));
1044
+ }
1031
1045
  }
1032
1046
  const result = { schema: 'lattice.pull_worker_attach_result.v1', outcome: 'attached',
1033
1047
  task_id: taskId, pid: input.pid, stopped: project(current.events, current.meta)
@@ -1049,7 +1063,7 @@ export async function attachPullWorker({ runDir, taskId, input, environment = pr
1049
1063
  * never verifiable and the hold was permanent.
1050
1064
  *
1051
1065
  * Authorization is process identity, not the actor: `signalAttachedWorker`
1052
- * re-verifies lstart/argv/pgid against the recorded binding before signalling,
1066
+ * re-verifies pid+lstart against the recorded binding before signalling,
1053
1067
  * which is strictly stronger than an env-derived actor claim (env vars are
1054
1068
  * trivially settable; a process's start identity is not). See
1055
1069
  * `optionalActorFromEnvironment` for why the seat's own identity cannot be the
@@ -1074,9 +1088,10 @@ export async function detachPullWorker({ runDir, taskId, environment = process.e
1074
1088
  // Resume before unbinding. Once the binding is gone nothing in this store can
1075
1089
  // name that pid again, so a worker left stopped here would be unreachable by
1076
1090
  // any future command — the same one-way door, one step further along.
1077
- const resumed = intake.worker.stopped;
1091
+ const resumed = intake.worker.stopped
1092
+ ? await signalAttachedWorker(intake, 'SIGCONT')
1093
+ : false;
1078
1094
  if (resumed) {
1079
- await signalAttachedWorker(intake, 'SIGCONT');
1080
1095
  current = await appendEvent(runDir, current, buildEvent({
1081
1096
  events: current.events, meta: current.meta, kind: 'worker_resumed', taskId,
1082
1097
  payload: { released_by: 'worker_detach' },
@@ -1220,11 +1235,12 @@ export async function acceptPullTask({ repoRoot, runDir, taskId, environment = p
1220
1235
  const stoppedIntake = project(current.events, current.meta).intakes
1221
1236
  .find((entry) => entry.task_id === affectedTaskId);
1222
1237
  if (stoppedIntake.worker && !stoppedIntake.worker.stopped) {
1223
- await signalAttachedWorker(stoppedIntake, 'SIGSTOP');
1224
- current = await appendEvent(runDir, current, buildEvent({
1225
- events: current.events, meta: current.meta, kind: 'worker_stopped', taskId: affectedTaskId,
1226
- payload: { reason: 'runtime_conflict' },
1227
- }));
1238
+ if (await signalAttachedWorker(stoppedIntake, 'SIGSTOP')) {
1239
+ current = await appendEvent(runDir, current, buildEvent({
1240
+ events: current.events, meta: current.meta, kind: 'worker_stopped', taskId: affectedTaskId,
1241
+ payload: { reason: 'runtime_conflict' },
1242
+ }));
1243
+ }
1228
1244
  }
1229
1245
  }
1230
1246
  fail('RUNTIME_CONFLICT_HOLD', 'observed diffがruntime conflictを生成した', { findings });
@@ -1236,11 +1252,12 @@ export async function acceptPullTask({ repoRoot, runDir, taskId, environment = p
1236
1252
  const resumed = project(current.events, current.meta).intakes
1237
1253
  .find((entry) => entry.task_id === taskId);
1238
1254
  if (resumed.worker?.stopped) {
1239
- await signalAttachedWorker(resumed, 'SIGCONT');
1240
- current = await appendEvent(runDir, current, buildEvent({
1241
- events: current.events, meta: current.meta, kind: 'worker_resumed', taskId,
1242
- payload: { released_by_empty_findings: true },
1243
- }));
1255
+ if (await signalAttachedWorker(resumed, 'SIGCONT')) {
1256
+ current = await appendEvent(runDir, current, buildEvent({
1257
+ events: current.events, meta: current.meta, kind: 'worker_resumed', taskId,
1258
+ payload: { released_by_empty_findings: true },
1259
+ }));
1260
+ }
1244
1261
  }
1245
1262
  }
1246
1263
  current = await appendEvent(runDir, current, buildEvent({
@@ -1265,11 +1282,12 @@ export async function acceptPullTask({ repoRoot, runDir, taskId, environment = p
1265
1282
  const resumed = project(current.events, current.meta).intakes
1266
1283
  .find((entry) => entry.task_id === waiting.task_id);
1267
1284
  if (resumed.worker?.stopped) {
1268
- await signalAttachedWorker(resumed, 'SIGCONT');
1269
- current = await appendEvent(runDir, current, buildEvent({
1270
- events: current.events, meta: current.meta, kind: 'worker_resumed', taskId: waiting.task_id,
1271
- payload: { released_by_accepted_task: taskId },
1272
- }));
1285
+ if (await signalAttachedWorker(resumed, 'SIGCONT')) {
1286
+ current = await appendEvent(runDir, current, buildEvent({
1287
+ events: current.events, meta: current.meta, kind: 'worker_resumed', taskId: waiting.task_id,
1288
+ payload: { released_by_accepted_task: taskId },
1289
+ }));
1290
+ }
1273
1291
  }
1274
1292
  }
1275
1293
  state = project(current.events, current.meta);
@@ -1293,11 +1311,12 @@ export async function acceptPullTask({ repoRoot, runDir, taskId, environment = p
1293
1311
  const resumed = project(current.events, current.meta).intakes
1294
1312
  .find((entry) => entry.task_id === waiting.task_id);
1295
1313
  if (resumed.worker?.stopped) {
1296
- await signalAttachedWorker(resumed, 'SIGCONT');
1297
- current = await appendEvent(runDir, current, buildEvent({
1298
- events: current.events, meta: current.meta, kind: 'worker_resumed', taskId: waiting.task_id,
1299
- payload: { released_by_accepted_task: taskId },
1300
- }));
1314
+ if (await signalAttachedWorker(resumed, 'SIGCONT')) {
1315
+ current = await appendEvent(runDir, current, buildEvent({
1316
+ events: current.events, meta: current.meta, kind: 'worker_resumed', taskId: waiting.task_id,
1317
+ payload: { released_by_accepted_task: taskId },
1318
+ }));
1319
+ }
1301
1320
  }
1302
1321
  }
1303
1322
  const result = { schema: 'lattice.pull_accept_result.v1', outcome: 'accepted',
@@ -1,6 +1,5 @@
1
1
  import net from 'node:net';
2
2
  import { createHash, randomBytes } from 'node:crypto';
3
- import { execFile } from 'node:child_process';
4
3
  import { constants as fsConstants, readFileSync } from 'node:fs';
5
4
  import {
6
5
  chmod,
@@ -13,7 +12,6 @@ import {
13
12
  rm,
14
13
  } from 'node:fs/promises';
15
14
  import path from 'node:path';
16
- import { promisify } from 'node:util';
17
15
 
18
16
  import { canonicalizeArtifact, digestArtifact } from './artifact-contracts.mjs';
19
17
  import {
@@ -32,6 +30,7 @@ import {
32
30
  import { captureWorktreeDiff } from './runtime-diff-observer.mjs';
33
31
  import { validateSupervisorWriteGate } from './runtime-gate-store.mjs';
34
32
  import { observeManagedProcessStartIdentity } from './runtime-managed-supervisor.mjs';
33
+ import { observePsSnapshot } from './runtime-os-observation.mjs';
35
34
  import {
36
35
  scriptedWorktreeId as workOrderWorktreeId,
37
36
  scriptedWorktreePath as workOrderWorktreePath,
@@ -45,7 +44,6 @@ import {
45
44
  const MAX_DOCUMENT_BYTES = 8_388_608;
46
45
  const SHA256 = /^[0-9a-f]{64}$/u;
47
46
  const ID = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/u;
48
- const execFileAsync = promisify(execFile);
49
47
  const REQUEST_SCHEMA_TO_OPERATION = Object.freeze(Object.fromEntries([
50
48
  ['lattice.adapter_dispatch_request.v1', 'dispatch'],
51
49
  ['lattice.adapter_observe_request.v1', 'observe'],
@@ -328,12 +326,7 @@ function quiescedState(rawState) {
328
326
  async function observeWorkerProcessTree(pid, { requireDescendantsInRootGroup = true } = {}) {
329
327
  let stdout;
330
328
  try {
331
- ({ stdout } = await execFileAsync('/bin/ps', [
332
- '-axo', 'pid=,ppid=,pgid=,state=,lstart=',
333
- ], {
334
- encoding: 'utf8',
335
- maxBuffer: 8 * 1024 * 1024,
336
- }));
329
+ stdout = await observePsSnapshot();
337
330
  } catch { fail('WORK_ORDER_REPORT_INVALID', 'worker_pidをOS観測できない'); }
338
331
  const records = new Map();
339
332
  for (const rawLine of stdout.split('\n')) {