@quolu/lattice 0.26.0 → 0.27.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Lattice — phase-aware TODO graph compiler and conflict-aware orchestration runtime",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -60,7 +60,8 @@
60
60
  "test": "node scripts/run-product-tests.mjs",
61
61
  "test:sensor": "npm --prefix sensor test",
62
62
  "check": "node scripts/check-syntax.mjs",
63
- "ci": "npm run test && npm run test:sensor && npm run check && npm run verify:todo-store",
64
- "verify:todo-store": "node bin/lattice.mjs todo verify --json"
63
+ "ci": "npm run test && npm run test:sensor && npm run check && npm run check:cli-surface && npm run verify:todo-store",
64
+ "verify:todo-store": "node bin/lattice.mjs todo verify --json",
65
+ "check:cli-surface": "node scripts/verify-cli-surface.mjs"
65
66
  }
66
67
  }
package/src/cli-help.mjs CHANGED
@@ -44,6 +44,10 @@ Commands:
44
44
  close --run .lattice/runs/<id>
45
45
  abandon --run .lattice/runs/<id> --reason <reason>
46
46
  list --json
47
+ seam resolve --run .lattice/runs/<id> --finding <digest> --input <request.json>
48
+ # 記録済み競合を隔離worktreeで実際に変換し、五条件を通ればseam splitと後継baseを返す。
49
+ # 入力(lattice.runtime_seam_request.v1)へ書くのは、係争fileの中で各TODOが触るsymbolと
50
+ # 新しい面の名前だけ。branchは動かさないので、後継baseへ進めるかは呼び出し側が決める。
47
51
  `,
48
52
  event: `Usage: lattice event verify --run .lattice/runs/<id>
49
53
  `,
@@ -119,6 +123,8 @@ registerはLATTICE_BRIDGE_REGISTRAR_SSH_HOSTとLATTICE_BRIDGE_REGISTRAR_SCRIPT
119
123
  });
120
124
 
121
125
  const SUBCOMMAND_USAGE = Object.freeze({
126
+ status: 'status --json',
127
+ 'session-context': 'session-context --json',
122
128
  'plan create': 'plan create --input <file> | --schema --json | --schema-version <2|3> --json',
123
129
  'plan compile': 'plan compile --request <request.json> | --schema --json',
124
130
  'plan verify': 'plan verify --request <request.json> --plan <plan.json>',
@@ -131,6 +137,13 @@ const SUBCOMMAND_USAGE = Object.freeze({
131
137
  'run close': 'run close --run .lattice/runs/<id>',
132
138
  'run abandon': 'run abandon --run .lattice/runs/<id> --reason <reason>',
133
139
  'run list': 'run list --json',
140
+ 'run activate': 'run activate --run .lattice/runs/<id>',
141
+ 'run conflict': 'run conflict --run .lattice/runs/<id> --finding <digest>',
142
+ 'run hold': 'run hold --run .lattice/runs/<id> --finding <digest>',
143
+ 'run recompile': 'run recompile --run .lattice/runs/<id> --input <recompile-request.json>',
144
+ 'run reprocess': 'run reprocess --run .lattice/runs/<id>',
145
+ 'run finding record': 'run finding record --run .lattice/runs/<id> --checkpoint <digest> --input <candidate.json>',
146
+ 'run seam resolve': 'run seam resolve --run .lattice/runs/<id> --finding <digest> --input <seam-request.json>',
134
147
  'event verify': 'event verify --run .lattice/runs/<id>',
135
148
  'todo status': 'todo status [--json]',
136
149
  'todo bindings': 'todo bindings [--plan <key>] [--json]',
@@ -155,6 +168,7 @@ const SUBCOMMAND_USAGE = Object.freeze({
155
168
  'todo revise': 'todo revise --plan <key> --input <file>',
156
169
  'todo revise-phase': 'todo revise-phase --plan <key> --input <file>',
157
170
  'todo revise-set': 'todo revise-set --input <file>',
171
+ 'todo migrate': 'todo migrate --input <extraction.json>',
158
172
  'sensor init': 'sensor init [path] --json',
159
173
  'sensor sync': 'sensor sync [path] --json',
160
174
  'runtime-errors snapshot': 'runtime-errors snapshot [--after-cursor <n>] [--limit <n>] --json',
@@ -192,5 +206,10 @@ export function renderCliHelp(argv) {
192
206
  return usage === undefined ? null : `Usage: lattice ${usage}\n`;
193
207
  }
194
208
  const namespace = requestedNamespace(argv);
195
- return namespace === null ? null : NAMESPACE_HELP[namespace] ?? null;
209
+ if (namespace === null) return null;
210
+ // namespaceを持たない1語コマンド(status等)は、SUBCOMMAND_USAGEへ落ちる。
211
+ // 落とさないと「存在するのに使い方を知る手段が無い」コマンドになる。
212
+ if (NAMESPACE_HELP[namespace] !== undefined) return NAMESPACE_HELP[namespace];
213
+ const usage = SUBCOMMAND_USAGE[namespace];
214
+ return usage === undefined ? null : `Usage: lattice ${usage}\n`;
196
215
  }
@@ -15,10 +15,15 @@ import {
15
15
  writeFile,
16
16
  } from 'node:fs/promises';
17
17
  import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
18
19
 
19
20
  import { canonicalizeArtifact, digestArtifact } from './artifact-contracts.mjs';
20
21
  import { collectSensorEvidence } from './sensor-adapter.mjs';
21
22
  import { detectCheckpointFindings } from './runtime-diff-observer.mjs';
23
+ import {
24
+ buildRuntimeSeamResolution, readRuntimeFindingRecord, resolveRuntimeSeam,
25
+ validateRuntimeSeamRequest, verifySeamSplitSuccessor,
26
+ } from './runtime-seam-resolve.mjs';
22
27
  import {
23
28
  compileRuntimePlanV1,
24
29
  evidenceFromCollectedOutcomes,
@@ -995,6 +1000,51 @@ async function runStart({ requestPath, executorAdapter, cwd, stdout }) {
995
1000
  return 0;
996
1001
  }
997
1002
 
1003
+ /**
1004
+ * 記録済み競合findingを、実際の変換で解消する(請求項8・ADR 0137〜0141)。
1005
+ *
1006
+ * 事前宣言されたtreatmentが無い競合は、これが無い間ずっと意図的直列へ退化していた。
1007
+ * 隔離worktreeで変換し、五条件(ADR 0138)を通り、確定できた時だけseam splitを返す。
1008
+ *
1009
+ * branchは動かさない。返す`successor_base_sha`へ進めるかどうかは操作するAIが決める——
1010
+ * 静的側の`land`と同じ責務分担であり、Latticeは変換・検証・記録と、後継baseの検査を持つ。
1011
+ */
1012
+ async function runSeamResolve({ runDir, repoRoot, findingDigest, requestPath, stdout }) {
1013
+ const committed = await readCommittedEpochStore(runDir);
1014
+ if (committed === null) {
1015
+ throw new CliContractError('RUN_NOT_MANAGED', 'runがmanaged storeへactivateされていない');
1016
+ }
1017
+ const declaration = await readBoundedJson(requestPath, 'runtime seam request');
1018
+ if (!validateRuntimeSeamRequest(declaration)) {
1019
+ throw new CliContractError('INVALID_SEAM_REQUEST', 'lattice.runtime_seam_request.v1として不正');
1020
+ }
1021
+ if (declaration.run_id !== committed.meta.run_id) {
1022
+ throw new CliContractError('INVALID_SEAM_REQUEST', '宣言のrun_idがrun storeと一致しない');
1023
+ }
1024
+ if (declaration.finding_digest !== findingDigest) {
1025
+ throw new CliContractError('INVALID_SEAM_REQUEST', '宣言のfinding_digestが--findingと一致しない');
1026
+ }
1027
+ const found = await readRuntimeFindingRecord({
1028
+ runDir, findingDigest, planEpoch: committed.pointer.plan_epoch,
1029
+ });
1030
+ if (found.record === null) throw new CliContractError('STALE_FINDING', found.reason);
1031
+
1032
+ const resolved = await resolveRuntimeSeam({
1033
+ repoRoot,
1034
+ runDir,
1035
+ findingRecord: found.record,
1036
+ bundle: committed.bundle,
1037
+ declaration,
1038
+ latticeBin: fileURLToPath(new URL('../bin/lattice.mjs', import.meta.url)),
1039
+ compiledAt: canonicalNow(),
1040
+ });
1041
+ const resolution = buildRuntimeSeamResolution({
1042
+ runId: committed.meta.run_id, findingDigest, resolved,
1043
+ });
1044
+ stdout.write(`${JSON.stringify(resolution)}\n`);
1045
+ return resolution.lane === 'seam_transform' ? 0 : 1;
1046
+ }
1047
+
998
1048
  async function runObserve({ runDir, stdout }) {
999
1049
  const { events } = await readRunStore(runDir);
1000
1050
  const chain = verifyRunEventChain({ events });
@@ -2068,6 +2118,27 @@ export async function runManagedSupervisorDaemon({
2068
2118
  newPlan.conflicts.sort((left, right) => `${left.todo_ids.join('\0')}\0${left.resource_id}`
2069
2119
  .localeCompare(`${right.todo_ids.join('\0')}\0${right.resource_id}`));
2070
2120
  }
2121
+ if (recompileRequest.mode === 'seam_split') {
2122
+ // 変換を含まないbaseを指したseam_splitを通さない(ADR 0141)。splitが新しい面の
2123
+ // 所有を宣言するのに後継treeにそのfileが無い、という状態を作らせない。
2124
+ const predecessorBaseSha = active.bundle.request.repo.base_sha;
2125
+ const successorBaseSha = recompileRequest.successor_request.repo.base_sha;
2126
+ const ancestry = await runGit(
2127
+ ['merge-base', '--is-ancestor', predecessorBaseSha, successorBaseSha], repoRoot,
2128
+ );
2129
+ const verdict = verifySeamSplitSuccessor({
2130
+ split: recompileRequest.seam_split,
2131
+ predecessorBaseSha,
2132
+ successorBaseSha,
2133
+ successorIsDescendant: ancestry.code === 0,
2134
+ successorConflicts: newPlan.conflicts,
2135
+ successorWitness: recompileRequest.successor_request.manual_witness,
2136
+ });
2137
+ if (!verdict.ok) {
2138
+ throw new ManagedRuntimeError('SEAM_SPLIT_UNPROVEN',
2139
+ `後継baseがseam splitの主張を満たさない: ${verdict.reasons.join(', ')}`);
2140
+ }
2141
+ }
2071
2142
  newPlan.plan_digest = selfDigest(newPlan, 'plan_digest');
2072
2143
  const executorPackets = buildExecutorPackets({ plan: newPlan, manifests: compiled.manifests });
2073
2144
 
@@ -3189,6 +3260,16 @@ export async function runRuntimeCli({ argv, cwd, stdout, stderr }) {
3189
3260
  requestId: requestIdOverride,
3190
3261
  });
3191
3262
  };
3263
+ } else if (argv.length === 9
3264
+ && argv[0] === 'run' && argv[1] === 'seam' && argv[2] === 'resolve'
3265
+ && argv[3] === '--run' && typeof argv[4] === 'string' && argv[4].length > 0
3266
+ && argv[5] === '--finding' && /^[0-9a-f]{64}$/u.test(argv[6])
3267
+ && argv[7] === '--input' && typeof argv[8] === 'string' && argv[8].length > 0) {
3268
+ action = async () => {
3269
+ const { repoRoot, runDir } = await resolveRunStore(cwd, argv[4]);
3270
+ return runSeamResolve({ runDir, repoRoot, findingDigest: argv[6],
3271
+ requestPath: path.resolve(cwd, argv[8]), stdout });
3272
+ };
3192
3273
  } else if (argv.length === 8
3193
3274
  && argv[0] === 'run' && argv[1] === 'finding' && argv[2] === 'record'
3194
3275
  && argv[3] === '--run' && typeof argv[4] === 'string' && argv[4].length > 0
@@ -65,7 +65,6 @@ function sha16(value) {
65
65
 
66
66
  const WITNESS_KINDS = Object.freeze(['state', 'schema', 'invariant', 'effect', 'external_effect']);
67
67
  const HEX_DIGEST = /^[0-9a-f]{64}$/u;
68
- const HEX_SHA1 = /^[0-9a-f]{40}$/u;
69
68
  const IDENTIFIER = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/u;
70
69
  const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
71
70
 
@@ -616,24 +615,20 @@ export function routeConflictTreatment(options = {}) {
616
615
  * (content不変・epoch/plan refのみ更新)、hold TODOへは新plan_ref由来の
617
616
  * 新context packetを発行し、旧contextを失効する。rebind/prepare ack、epoch pointer、
618
617
  * controller ready/release ack、中央gate commit前にはepoch_rebound/intake_resumedを発行しない。
618
+ *
619
+ * baseはここでは動かさない。変換で構造が変わった時に後継baseを決めるのは後継run requestで
620
+ * あり、それが本当に変換を含むかは`verifySeamSplitSuccessor`が見る(ADR 0141)。
619
621
  */
620
622
  export function recompileNextEpochPlan(options = {}) {
621
623
  if (!exactRecord(options, [
622
624
  'runId', 'request', 'plan', 'manifests', 'packets', 'events', 'holdDecision',
623
625
  'additionalConflicts', 'recordedAt',
624
- ]) && !exactRecord(options, [
625
- 'runId', 'request', 'plan', 'manifests', 'packets', 'events', 'holdDecision',
626
- 'additionalConflicts', 'recordedAt', 'successorBaseSha',
627
626
  ])) {
628
627
  fail('recompileNextEpochPlan optionsがexact shapeでない');
629
628
  }
630
629
  const {
631
630
  runId, request, plan, manifests, packets, events, holdDecision, additionalConflicts, recordedAt,
632
- successorBaseSha = null,
633
631
  } = options;
634
- if (successorBaseSha !== null && !HEX_SHA1.test(successorBaseSha)) {
635
- fail('successorBaseShaがgit shaでない');
636
- }
637
632
  if (!validateRuntimePlan(plan)) fail('planがruntime_plan.v1 contractを満たさない');
638
633
  if (!validateHoldDecision(holdDecision)) fail('holdDecisionがcontractを満たさない');
639
634
  if (!Array.isArray(additionalConflicts)) fail('additionalConflictsがarrayではない');
@@ -681,11 +676,7 @@ export function recompileNextEpochPlan(options = {}) {
681
676
  plan_ref: newPlanRef,
682
677
  plan_epoch: newEpoch,
683
678
  request_digest: request.request_digest,
684
- // 変換でsourceの構造が変わったなら、再開先は変換を含むbaseでなければならない。
685
- // 旧baseのまま再開させると、splitが所有を宣言した新pathがworktreeに存在しない(ADR 0141)。
686
- // carry-over側のrebind packetはcontent不変が要件なので、そちらは触らない——
687
- // 継続する作業は自分のworktreeで走り続けており、baseを付け替える対象ではない。
688
- base_sha: successorBaseSha ?? plan.base_sha,
679
+ base_sha: plan.base_sha,
689
680
  nodes: structuredClone(plan.nodes),
690
681
  precedence: structuredClone(plan.precedence),
691
682
  conflicts: mergedConflicts,
@@ -0,0 +1,283 @@
1
+ /**
2
+ * 実行時に観測した競合を、実際の変換で解消するproduct入口(請求項8)。
3
+ *
4
+ * これが無い間、変換の中身は動くのに実運転からそこへ行く道が無かった。実運転側が使う
5
+ * `routeConflictTreatment`は「事前宣言済みtreatmentがpathを覆う時だけseam_transform」なので、
6
+ * **予期しなかった競合は変換にかからず直列へ退化していた**。
7
+ *
8
+ * 装置の境界にAIを含める(AGENTS.md)。したがってここでLatticeが供給するのは、AIが自分では
9
+ * 作れないもの——構造観測、隔離実行、五条件の検証、変換の確定と記録——だけである。
10
+ * 「どのTODOが係争fileの中のどのsymbolを触るか」「新しい面をどう名付けるか」はAIが既に
11
+ * 知っているので、宣言として受け取る。推定しない。
12
+ */
13
+
14
+ import path from 'node:path';
15
+ import { createHash } from 'node:crypto';
16
+ import { readFile } from 'node:fs/promises';
17
+
18
+ import { digestArtifact } from './artifact-contracts.mjs';
19
+ import { commitSeamTransform } from './seam-commit.mjs';
20
+ import { applySeamConflict } from './seam-apply.mjs';
21
+ import { resolveRuntimeSeamTreatment } from './runtime-seam-treatment.mjs';
22
+ import { collectWitnessSensorEvidence, compileTodoIndependence } from './todo-independence.mjs';
23
+ import { todoSelfDigest } from './todo-contracts.mjs';
24
+
25
+ export const RUNTIME_SEAM_REQUEST_SCHEMA = 'lattice.runtime_seam_request.v1';
26
+ export const RUNTIME_SEAM_RESOLUTION_SCHEMA = 'lattice.runtime_seam_resolution.v1';
27
+
28
+ /** 合成するtodo planのkey。実行時planとは別空間なので固定でよい。 */
29
+ const SYNTHETIC_PLAN_KEY = 'runtime';
30
+ const HEX_DIGEST = /^[0-9a-f]{64}$/u;
31
+ const REPO_PATH = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[\w./-]+$/u;
32
+ const IDENTIFIER = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/u;
33
+
34
+ const compareText = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
35
+
36
+ /** 資源idはruntime front-endと同じ合成形にする(`own-<kind>-<sha16>`)。 */
37
+ const sha16 = (value) => createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 16);
38
+
39
+ function plainObject(value) {
40
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
41
+ }
42
+
43
+ function exactRecord(value, keys) {
44
+ if (!plainObject(value)) return false;
45
+ const own = Object.keys(value);
46
+ return own.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
47
+ }
48
+
49
+ /**
50
+ * AIが出す宣言。ここに書くのは「係争fileの中で各TODOが触るsymbol」「新しい面の名前」
51
+ * 「後継planへ渡すtask migrationのdigest」だけで、どれもAIが既に持っている情報である。
52
+ */
53
+ export function validateRuntimeSeamRequest(value) {
54
+ if (!exactRecord(value, [
55
+ 'schema', 'run_id', 'finding_digest', 'concern_symbols', 'path_names',
56
+ 'task_migration_digest', 'request_digest',
57
+ ])) return false;
58
+ if (value.schema !== RUNTIME_SEAM_REQUEST_SCHEMA) return false;
59
+ if (!IDENTIFIER.test(value.run_id ?? '')) return false;
60
+ if (!HEX_DIGEST.test(value.finding_digest ?? '')) return false;
61
+ if (!HEX_DIGEST.test(value.task_migration_digest ?? '')) return false;
62
+ if (!plainObject(value.concern_symbols) || !plainObject(value.path_names)) return false;
63
+
64
+ const todoIds = Object.keys(value.concern_symbols);
65
+ if (todoIds.length < 2 || todoIds.length > 64) return false;
66
+ for (const todoId of todoIds) {
67
+ if (!IDENTIFIER.test(todoId)) return false;
68
+ const symbols = value.concern_symbols[todoId];
69
+ if (!Array.isArray(symbols) || symbols.length === 0 || symbols.length > 256) return false;
70
+ if (!symbols.every((symbol) => IDENTIFIER.test(symbol))) return false;
71
+ if (!REPO_PATH.test(value.path_names[todoId] ?? '')) return false;
72
+ }
73
+ // sharedはresidualから切り出す共有面。TODOに属さないので別枠で必ず要る。
74
+ if (!REPO_PATH.test(value.path_names.shared ?? '')) return false;
75
+ if (!exactRecord(value.path_names, [...todoIds, 'shared'])) return false;
76
+ if (!HEX_DIGEST.test(value.request_digest ?? '')) return false;
77
+ return value.request_digest === todoSelfDigest(value, 'request_digest');
78
+ }
79
+
80
+ /**
81
+ * 実行時witnessへconcern anchorを足してtodo witness setにする。
82
+ *
83
+ * 実行時のmanual_witnessはconcern_anchorsを持たない(`lattice.run_request.v3`)。持たせるのでなく、
84
+ * 宣言から足す——係争資源の中のどのsymbolを触るかは実行時に確定する情報であり、run開始時点の
85
+ * 契約に書けるものではないからである。
86
+ */
87
+ export function buildRuntimeSeamWitnessSet({ request, declaration, contestedPath, executors }) {
88
+ const todoIds = Object.keys(declaration.concern_symbols).sort(compareText);
89
+ const manual = {};
90
+ for (const todoId of todoIds) {
91
+ const witness = request.manual_witness?.[todoId];
92
+ if (!plainObject(witness)) return { witnessSet: null, reasons: [`witness_absent:${todoId}`] };
93
+ manual[todoId] = {
94
+ ...structuredClone(witness),
95
+ concern_anchors: [{
96
+ within: { kind: 'path', target: contestedPath },
97
+ symbols: [...declaration.concern_symbols[todoId]].sort(compareText),
98
+ }],
99
+ };
100
+ }
101
+ const witnessSet = {
102
+ schema: 'lattice.todo_witness_set.v3',
103
+ project_id: SYNTHETIC_PLAN_KEY,
104
+ plan_key: SYNTHETIC_PLAN_KEY,
105
+ capacity: { executors },
106
+ sensor_query_set: structuredClone(request.sensor_query_set),
107
+ manual_witness: manual,
108
+ witness_set_digest: '',
109
+ };
110
+ witnessSet.witness_set_digest = todoSelfDigest(witnessSet, 'witness_set_digest');
111
+ return { witnessSet, reasons: [] };
112
+ }
113
+
114
+ function syntheticTodoPlan(todoIds) {
115
+ return {
116
+ schema: 'lattice.todo_plan.v2',
117
+ project_id: SYNTHETIC_PLAN_KEY,
118
+ plan_key: SYNTHETIC_PLAN_KEY,
119
+ plan_version: 'v1',
120
+ topology_digest: digestArtifact({ tasks: [...todoIds].sort(compareText) }),
121
+ tasks: [...todoIds].sort(compareText).map((taskId) => ({ task_id: taskId })),
122
+ };
123
+ }
124
+
125
+ /**
126
+ * 記録済みfindingと宣言から、変換を導出・適用・検証・確定して処置を返す。
127
+ *
128
+ * 五条件(ADR 0138)を1つでも欠いたら意図的直列を返す。確定できなければ採用しない(ADR 0141)。
129
+ * 「変換した」と言いながら再開できない状態を作らない。
130
+ */
131
+ export async function resolveRuntimeSeam({
132
+ repoRoot, runDir, findingRecord, bundle, declaration, latticeBin, compiledAt,
133
+ } = {}) {
134
+ const finding = findingRecord.finding;
135
+ const request = bundle.request;
136
+ const baseSha = request.repo.base_sha;
137
+ const todoIds = Object.keys(declaration.concern_symbols).sort(compareText);
138
+
139
+ if (finding.kind !== 'observed_write_conflict' || typeof finding.path !== 'string') {
140
+ return { lane: 'intentional_serial', reasons: ['finding_not_write_conflict'], split: null };
141
+ }
142
+ const findingTodoIds = [...finding.todo_ids].sort(compareText);
143
+ if (findingTodoIds.join('\0') !== todoIds.join('\0')) {
144
+ return {
145
+ lane: 'intentional_serial',
146
+ reasons: ['declared_todos_differ_from_finding'],
147
+ split: null,
148
+ };
149
+ }
150
+
151
+ const built = buildRuntimeSeamWitnessSet({
152
+ request, declaration, contestedPath: finding.path,
153
+ executors: request.capacity.executors,
154
+ });
155
+ if (built.witnessSet === null) {
156
+ return { lane: 'intentional_serial', reasons: built.reasons, split: null };
157
+ }
158
+ const witnessSet = built.witnessSet;
159
+ const plan = syntheticTodoPlan(todoIds);
160
+
161
+ // 観測したaffected testsだけを検証に使う。宣言から発明しない。
162
+ const affectedTests = [...new Set(todoIds
163
+ .flatMap((todoId) => request.manual_witness[todoId].affected_tests))].sort(compareText);
164
+
165
+ const baseArtifact = compileTodoIndependence({
166
+ witnessSet, plan, baseSha, compiledAt,
167
+ sensorEvidence: await collectWitnessSensorEvidence({ cwd: repoRoot, witnessSet }),
168
+ });
169
+
170
+ const pathNames = { ...declaration.path_names };
171
+ return resolveRuntimeSeamTreatment({
172
+ finding,
173
+ witnessSet,
174
+ pathNames,
175
+ baseSha,
176
+ manifestDigest: baseArtifact.result_digest,
177
+ affectedTests,
178
+ taskMigrationDigest: declaration.task_migration_digest,
179
+ commitTransform: async ({ files, candidateId }) => commitSeamTransform({
180
+ repoRoot, baseSha, files, candidateId,
181
+ }),
182
+ applyConflict: async ({ conflict }) => {
183
+ const applied = await applySeamConflict({
184
+ repoRoot,
185
+ planKey: SYNTHETIC_PLAN_KEY,
186
+ conflict,
187
+ witnessSet,
188
+ latticeBin,
189
+ sharedPathFor: () => declaration.path_names.shared,
190
+ executors: request.capacity.executors,
191
+ pathNames,
192
+ compileIndependence: {
193
+ baseArtifact,
194
+ inWorktree: async ({ worktreePath, witnessSet: postWitness }) => compileTodoIndependence({
195
+ witnessSet: postWitness,
196
+ plan,
197
+ baseSha,
198
+ compiledAt,
199
+ sensorEvidence: await collectWitnessSensorEvidence({
200
+ cwd: worktreePath, witnessSet: postWitness,
201
+ }),
202
+ }),
203
+ },
204
+ });
205
+ return { ...applied, candidate: applied.candidate ?? null };
206
+ },
207
+ });
208
+ }
209
+
210
+ /** 決着をartifactにする。branchを動かすのは操作するAIなので、行き先を明示して返す。 */
211
+ export function buildRuntimeSeamResolution({ runId, findingDigest, resolved }) {
212
+ const resolution = {
213
+ schema: RUNTIME_SEAM_RESOLUTION_SCHEMA,
214
+ run_id: runId,
215
+ finding_digest: findingDigest,
216
+ lane: resolved.lane,
217
+ reasons: [...resolved.reasons].sort(compareText),
218
+ split: resolved.split ?? null,
219
+ successor_base_sha: resolved.successor_base_sha ?? null,
220
+ successor_base_ref: resolved.successor_base_ref ?? null,
221
+ resolution_digest: '',
222
+ };
223
+ resolution.resolution_digest = todoSelfDigest(resolution, 'resolution_digest');
224
+ return resolution;
225
+ }
226
+
227
+ /**
228
+ * seam_split再計画で、後継baseが本当に変換を含むかを検査する(ADR 0141)。
229
+ *
230
+ * これが無い間、`mode: 'seam_split'`の再計画requestは**変換を含まないbaseを指していても通った**。
231
+ * splitは新しい面の所有を宣言するのに、compileされる後継treeにそのfileが無い——rb工程で
232
+ * 直したのと同じ欠陥が、管理runtimeの層に残っていた。
233
+ *
234
+ * 検査するのは3つ。どれもLatticeが既に持っているartifactだけで判定でき、推定を含まない。
235
+ *
236
+ * 1. baseが前進し、かつ旧baseの子孫であること。変換が着地していなければ前進しない。
237
+ * 2. splitが「消える」と述べた競合辺が、後継planに実際に無いこと。後継treeに変換が
238
+ * 載っていなければ両TODOは同じfileを書き続けるので、この辺は消えない。
239
+ * 3. splitが新たに所有すると述べた資源が、後継requestで**creationとして宣言されていない**こと。
240
+ * seam splitは既存codeを新しい面へ移す操作であり、変換が既に作っている。これから作る、
241
+ * と宣言されているなら、指しているbaseは変換前である。
242
+ */
243
+ export function verifySeamSplitSuccessor({
244
+ split, predecessorBaseSha, successorBaseSha, successorIsDescendant,
245
+ successorConflicts = [], successorWitness = {},
246
+ } = {}) {
247
+ const reasons = [];
248
+ if (successorBaseSha === predecessorBaseSha) reasons.push('successor_base_not_advanced');
249
+ else if (successorIsDescendant !== true) reasons.push('successor_base_not_descendant');
250
+
251
+ const removedEdges = split?.edge_diff?.removed ?? [];
252
+ const remaining = new Set(successorConflicts
253
+ .map(({ todo_ids: ids }) => [...ids].sort(compareText).join('\0')));
254
+ for (const edge of removedEdges) {
255
+ const key = [edge.from_todo_id, edge.to_todo_id].sort(compareText).join('\0');
256
+ if (remaining.has(key)) reasons.push(`declared_removed_conflict_persists:${key.replace('\0', ',')}`);
257
+ }
258
+
259
+ for (const added of split?.ownership_diff?.added ?? []) {
260
+ const owns = successorWitness[added.owner_todo_id]?.owns ?? [];
261
+ for (const own of owns) {
262
+ const resourceId = `own-${own.kind}-${sha16(own.target)}`;
263
+ if (resourceId === added.resource_id && own.creates === true) {
264
+ reasons.push(`declared_owned_surface_is_creation:${added.owner_todo_id}`);
265
+ }
266
+ }
267
+ }
268
+ return { ok: reasons.length === 0, reasons: [...new Set(reasons)].sort(compareText) };
269
+ }
270
+
271
+ /** `<runDir>/findings/<digest>.json`を読む。存在しない/別epochのfindingは受けない。 */
272
+ export async function readRuntimeFindingRecord({ runDir, findingDigest, planEpoch }) {
273
+ const filePath = path.join(runDir, 'findings', `${findingDigest}.json`);
274
+ let record;
275
+ try {
276
+ record = JSON.parse(await readFile(filePath, 'utf8'));
277
+ } catch {
278
+ return { record: null, reason: 'finding_not_recorded' };
279
+ }
280
+ if (record?.finding_digest !== findingDigest) return { record: null, reason: 'finding_digest_mismatch' };
281
+ if (record.plan_epoch !== planEpoch) return { record: null, reason: 'finding_from_other_epoch' };
282
+ return { record, reason: null };
283
+ }
Binary file
Binary file