@tea-agent/loop-agent 0.42.0 → 0.43.0-next.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.
Files changed (78) hide show
  1. package/CHANGELOG.md +34 -7
  2. package/dist/application/evaluation/budget.js +19 -1
  3. package/dist/build-stamp.json +3 -3
  4. package/dist/executors/dag-pi-executor.js +762 -252
  5. package/dist/executors/pi-executor.js +20 -1
  6. package/dist/executors/pi-sdk-executor.js +64 -1
  7. package/dist/executors/shell-executor.js +5 -2
  8. package/dist/shared/frontend-execution-policy.js +22 -0
  9. package/dist/task/config-types.js +4 -0
  10. package/dist/task/source-prepare/ledger-reconciliation.js +2 -2
  11. package/dist/task/source-prepare/ledger-review.js +6 -9
  12. package/dist/task/source-prepare/semantic-intake.js +16 -26
  13. package/dist/task/source-prepare/source-fidelity-pi.js +26 -7
  14. package/dist/worker/observe/node-transparency.js +81 -72
  15. package/dist/worker/observe/routes.js +20 -1
  16. package/dist/worker/observe/static/dag-inspector-humanize.js +3 -0
  17. package/dist/worker/observe/static/dom.js +20 -1
  18. package/dist/worker/observe/static/format-pool.d.ts +2 -0
  19. package/dist/worker/observe/static/format-pool.js +6 -0
  20. package/dist/worker/observe/static/format.js +7 -0
  21. package/dist/worker/observe/static/inspect-workspace.js +34 -7
  22. package/dist/worker/observe/static/inspector-submission.js +32 -0
  23. package/dist/worker/observe/static/kpi.js +1 -0
  24. package/dist/worker/observe/static/relations.js +2 -0
  25. package/dist/worker/observe/static/router.js +13 -0
  26. package/dist/worker/observe/static/run-processing.js +2 -0
  27. package/dist/worker/observe/static/shell-chrome.js +36 -3
  28. package/dist/worker/observe/static/state.js +35 -2
  29. package/dist/worker/observe/static/styles.css +260 -39
  30. package/dist/worker/observe/static/task-failure-labels.d.ts +4 -0
  31. package/dist/worker/observe/static/task-failure-labels.js +67 -0
  32. package/dist/worker/observe/static/task-history.js +12 -0
  33. package/dist/worker/observe/static/views/batch.js +6 -13
  34. package/dist/worker/observe/static/views/dag-graph.js +50 -3
  35. package/dist/worker/observe/static/views/dag-inspector.js +746 -265
  36. package/dist/worker/observe/static/views/dag-trajectory.js +3 -0
  37. package/dist/worker/observe/static/views/dag.d.ts +6 -0
  38. package/dist/worker/observe/static/views/dag.js +48 -10
  39. package/dist/worker/observe/static/views/dags.js +2 -0
  40. package/dist/worker/observe/static/views/dashboard.js +21 -12
  41. package/dist/worker/observe/static/views/failures.js +21 -11
  42. package/dist/worker/observe/static/views/feature.js +11 -29
  43. package/dist/worker/observe/static/views/pool.js +37 -28
  44. package/dist/worker/observe/static/views/run.js +48 -5
  45. package/dist/worker/observe/static/views/session-timeline.js +189 -240
  46. package/dist/worker/observe/static/views/task.js +81 -62
  47. package/dist/workflows/dag/budget-enforcement.js +53 -3
  48. package/dist/workflows/dag/frontend-durable-tools.js +193 -0
  49. package/dist/workflows/dag/frontend-execution-groups.js +24 -0
  50. package/dist/workflows/dag/frontend-implementation-contract.js +9 -0
  51. package/dist/workflows/dag/frontend-input-projection.js +76 -0
  52. package/dist/workflows/dag/frontend-plan-render.js +10 -3
  53. package/dist/workflows/dag/frontend-recovery-controller.js +7 -7
  54. package/dist/workflows/dag/frontend-recovery-lineage.js +13 -0
  55. package/dist/workflows/dag/frontend-recovery-run.js +4 -0
  56. package/dist/workflows/dag/frontend-review-scopes.js +117 -0
  57. package/dist/workflows/dag/frontend-session-budget.js +249 -0
  58. package/dist/workflows/dag/frontend-shadow-dual-write.js +20 -2
  59. package/dist/workflows/dag/frontend-test-execution-evidence.js +3 -2
  60. package/dist/workflows/dag/frontend-typed-event-store.js +11 -0
  61. package/dist/workflows/dag/init-hybrid.js +16 -10
  62. package/dist/workflows/dag/node-execution.js +32 -155
  63. package/dist/workflows/dag/prompt.js +4 -0
  64. package/dist/workflows/dag/rerun-plan.js +7 -1
  65. package/dist/workflows/dag/runner.js +26 -1
  66. package/dist/workflows/dag/types.js +6 -0
  67. package/docs/operations/README.md +1 -0
  68. package/docs/templates/frontend-design-contract.md +4 -4
  69. package/docs/templates/frontend-implementation-contract.schema.json +34 -2
  70. package/docs/templates/frontend-implementation-dag.json +5 -5
  71. package/package.json +1 -1
  72. package/skills/frontend-contract/SKILL.md +2 -1
  73. package/skills/frontend-contract/references/contract-protocol.md +19 -3
  74. package/skills/frontend-design-review/SKILL.md +12 -11
  75. package/skills/frontend-plan/SKILL.md +2 -2
  76. package/skills/frontend-plan/references/decision-contract.md +18 -5
  77. package/skills/frontend-review/SKILL.md +10 -11
  78. package/skills/frontend-scout/references/scout-evidence.md +4 -0
@@ -1,3 +1,5 @@
1
+ import { inspectRouteStates } from "../state.js";
2
+ import { mergeTaskHistoryPage } from "../task-history.js";
1
3
  /** Observe UI view module (R5). */
2
4
  import {
3
5
  UI_TEXT,
@@ -16,7 +18,7 @@ import {
16
18
  DAG_EFFECTIVE_STATUS_LABELS,
17
19
  STATUS_LABELS,
18
20
  } from "../constants.js";
19
- import { clearNode, el, metaGrid, buildTable } from "../dom.js";
21
+ import { clearNode, el, metaGrid, buildTable, reconcileKeyed } from "../dom.js";
20
22
  import {
21
23
  badge,
22
24
  badgeClass,
@@ -45,6 +47,7 @@ import {
45
47
  import {
46
48
  fetchJson,
47
49
  fetchJsonResult,
50
+ summarizeFetchError,
48
51
  artifactUrl,
49
52
  parseArtifactPreviewResponse,
50
53
  } from "../api.js";
@@ -66,7 +69,7 @@ import {
66
69
  renderViewState,
67
70
  mountViewState,
68
71
  } from "../state.js";
69
- import { uiState } from "../state.js";
72
+ import { uiState, taskHistoryStates, workspaceScope } from "../state.js";
70
73
  import { sortPoolTasks } from "../format-pool.js";
71
74
  import {
72
75
  dagMetaVisibility,
@@ -75,7 +78,7 @@ import {
75
78
  findDagForRun,
76
79
  } from "../dag-model.js";
77
80
  import { layoutDag } from "../dag-layout.js";
78
- import { showView, setBreadcrumb, updateHeaderRefresh, mountRelationBar } from "../shell-chrome.js";
81
+ import { showView, setBreadcrumb, updateHeaderRefresh, mountRelationBar, bindManualViewRefresh } from "../shell-chrome.js";
79
82
  import { artifactLink } from "../kpi.js";
80
83
  import { findBatchRunIdForTask, taskRunHistoryRow } from "../run-processing.js";
81
84
 
@@ -91,7 +94,7 @@ export async function renderTaskDetail(featureId, taskId) {
91
94
  showView("task");
92
95
  setBreadcrumb([
93
96
  { label: UI_TEXT.dashboard, href: "#/" },
94
- { label: "资源池", href: "/#/inspect/pool" },
97
+ { label: "资源池", href: inspectRouteStates.get("return-list") ?? "/#/inspect/pool" },
95
98
  { label: displayLabel },
96
99
  ]);
97
100
 
@@ -103,13 +106,29 @@ export async function renderTaskDetail(featureId, taskId) {
103
106
  const ledgerEl = document.getElementById("task-ledger");
104
107
  if (!metaEl || !linksEl || !evidenceEl || !runsEl) return false;
105
108
  if (titleEl) titleEl.textContent = displayLabel;
106
- mountViewState(metaEl, "loading", "加载 Task…");
109
+ const identity = `${workspaceScope() ?? ""}:${featureId ?? ""}:${taskId}`;
110
+ const requestGeneration = uiState.pollingGeneration;
111
+ if (metaEl.dataset.taskIdentity !== identity) {
112
+ metaEl.dataset.taskIdentity = identity;
113
+ mountViewState(metaEl, "loading", "加载 Task…");
114
+ clearNode(runsEl);
115
+ for (const element of [linksEl, evidenceEl, ledgerEl]) if (element) clearNode(element);
116
+ }
117
+ if (!taskHistoryStates.has(identity)) taskHistoryStates.set(identity, { runs: [], nextBefore: null, loadedOlder: false, loadingOlder: false });
118
+ const historyState = taskHistoryStates.get(identity);
107
119
 
108
120
  const [taskResult, historyResult, snapshot] = await Promise.all([
109
121
  fetchJsonResult(taskApi),
110
122
  fetchJsonResult(`${taskApi}/runs?limit=20`),
111
123
  fetchJson("/api/snapshot"),
112
124
  ]);
125
+ if (requestGeneration !== uiState.pollingGeneration || metaEl.dataset.taskIdentity !== identity) return false;
126
+ if (!taskResult.ok && ![404, 409].includes(taskResult.status)) {
127
+ let error = metaEl.querySelector(".task-refresh-error");
128
+ if (!error) { error = el("p", "task-refresh-error node-input-error"); metaEl.appendChild(error); }
129
+ error.textContent = `更新失败,保留上次内容。${summarizeFetchError(taskResult)}`;
130
+ return true;
131
+ }
113
132
  const task = taskResult.ok ? taskResult.body : null;
114
133
  const history = historyResult.ok ? historyResult.body : null;
115
134
  if (snapshot) {
@@ -120,7 +139,6 @@ export async function renderTaskDetail(featureId, taskId) {
120
139
  clearNode(metaEl);
121
140
  clearNode(linksEl);
122
141
  clearNode(evidenceEl);
123
- clearNode(runsEl);
124
142
 
125
143
  if (!task) {
126
144
  if (taskResult.status === 409) {
@@ -234,27 +252,9 @@ export async function renderTaskDetail(featureId, taskId) {
234
252
  evidenceEl.appendChild(evidenceLinks);
235
253
  }
236
254
 
237
- runsEl.appendChild(el("h3", "section-heading", "运行历史"));
238
- const runs = history?.runs ?? [];
239
- if (runs.length === 0) {
240
- mountViewState(runsEl, "empty", "暂无 ledger 运行记录。");
241
- } else {
242
- const rows = runs.map(taskRunHistoryRow);
243
- runsEl.appendChild(
244
- buildTable(
245
- ["worker run", "状态", "时间", "batch", "retryOf", "失败分类"],
246
- rows,
247
- ),
248
- );
249
- if (history?.nextBefore) {
250
- const more = el("button", "link-button", "加载更早的运行");
251
- more.type = "button";
252
- more.addEventListener("click", () => {
253
- void loadOlderTaskRuns(featureId, taskId, history.nextBefore, runsEl);
254
- });
255
- runsEl.appendChild(more);
256
- }
257
- }
255
+ if (history) mergeTaskHistoryPage(historyState, history);
256
+ else historyState.error = summarizeFetchError(historyResult);
257
+ renderTaskHistory(runsEl, historyState, featureId, taskId, identity);
258
258
 
259
259
  const active = ["running", "pending", "blocked", "stale"].includes(
260
260
  (task.status ?? "").toLowerCase(),
@@ -262,6 +262,8 @@ export async function renderTaskDetail(featureId, taskId) {
262
262
  if (ledgerEl) {
263
263
  void renderTaskLedger(ledgerEl, taskId);
264
264
  }
265
+ updateHeaderRefresh(snapshot?.generatedAt ?? uiState.lastSnapshot?.generatedAt, { mode: active ? "auto" : "ended" });
266
+ bindManualViewRefresh("task", () => renderTaskDetail(featureId, taskId));
265
267
  return active;
266
268
  }
267
269
 
@@ -315,42 +317,59 @@ async function renderTaskLedger(ledgerEl, taskId) {
315
317
  ledgerEl.appendChild(pre);
316
318
  }
317
319
 
318
- export async function loadOlderTaskRuns(featureId, taskId, before, runsEl) {
319
- if (runsEl === undefined) {
320
- runsEl = before;
321
- before = taskId;
322
- taskId = featureId;
323
- featureId = undefined;
320
+ function renderTaskHistory(runsEl, state, featureId, taskId, identity) {
321
+ if (runsEl.dataset.historyIdentity !== identity) { clearNode(runsEl); runsEl.dataset.historyIdentity = identity; }
322
+ let table = runsEl.querySelector("table");
323
+ if (!table) {
324
+ runsEl.appendChild(el("h3", "section-heading", "运行历史"));
325
+ table = buildTable(["worker run", "状态", "时间", "batch", "retryOf", "失败分类"], []);
326
+ runsEl.appendChild(table);
327
+ const status = el("p", "muted task-history-status");
328
+ status.setAttribute("role", "status");
329
+ runsEl.appendChild(status);
330
+ const more = el("button", "link-button", "加载更早的运行");
331
+ more.type = "button";
332
+ more.addEventListener("click", () => { void loadOlderTaskRuns(featureId, taskId, state.nextBefore, runsEl); });
333
+ runsEl.appendChild(more);
324
334
  }
325
- const taskApi = featureId
326
- ? `/api/features/${encodeURIComponent(featureId)}/tasks/${encodeURIComponent(taskId)}`
327
- : `/api/tasks/${encodeURIComponent(taskId)}`;
328
- const page = await fetchJson(
329
- `${taskApi}/runs?limit=20&before=${encodeURIComponent(before)}`,
330
- );
331
- if (!page?.runs?.length) return;
332
- const table = runsEl.querySelector("table");
333
- if (!table) return;
334
- const tbody = table.querySelector("tbody") || table;
335
- for (const run of page.runs) {
336
- const tr = document.createElement("tr");
337
- for (const cell of taskRunHistoryRow(run).cells) {
338
- const td = document.createElement("td");
339
- if (cell instanceof Node) td.appendChild(cell);
340
- else td.textContent = cell;
341
- tr.appendChild(td);
342
- }
343
- tbody.appendChild(tr);
344
- }
345
- const btn = runsEl.querySelector("button.link-button");
346
- if (btn) {
347
- if (page.nextBefore) {
348
- btn.onclick = () => {
349
- void loadOlderTaskRuns(featureId, taskId, page.nextBefore, runsEl);
350
- };
351
- } else {
352
- btn.remove();
353
- }
335
+ reconcileKeyed(table.querySelector("tbody"), state.runs, {
336
+ getKey: (run) => run.workerRunId,
337
+ create: () => el("tr"),
338
+ update: (row, run) => {
339
+ const signature = JSON.stringify(run);
340
+ if (row.dataset.signature === signature) return;
341
+ clearNode(row);
342
+ for (const cell of taskRunHistoryRow(run).cells) {
343
+ const td = el("td");
344
+ if (cell instanceof Node) td.appendChild(cell); else td.textContent = cell ?? "—";
345
+ row.appendChild(td);
346
+ }
347
+ row.dataset.signature = signature;
348
+ },
349
+ });
350
+ const status = runsEl.querySelector(".task-history-status");
351
+ status.textContent = state.error ? `历史加载失败:${state.error}` : state.runs.length ? `已加载 ${state.runs.length} 次运行` : "暂无 ledger 运行记录。";
352
+ const more = runsEl.querySelector("button.link-button");
353
+ more.hidden = !state.nextBefore && !state.error;
354
+ more.disabled = state.loadingOlder;
355
+ more.textContent = state.loadingOlder ? "正在加载…" : state.error ? "重试加载" : "加载更早的运行";
356
+ }
357
+
358
+ export async function loadOlderTaskRuns(featureId, taskId, before, runsEl) {
359
+ const identity = `${workspaceScope() ?? ""}:${featureId ?? ""}:${taskId}`;
360
+ const state = taskHistoryStates.get(identity);
361
+ if (!state || state.loadingOlder) return;
362
+ state.loadingOlder = true;
363
+ renderTaskHistory(runsEl, state, featureId, taskId, identity);
364
+ const taskApi = featureId ? `/api/features/${encodeURIComponent(featureId)}/tasks/${encodeURIComponent(taskId)}` : `/api/tasks/${encodeURIComponent(taskId)}`;
365
+ try {
366
+ const result = await fetchJsonResult(`${taskApi}/runs?limit=20${state.nextBefore ? `&before=${encodeURIComponent(state.nextBefore)}` : ""}`);
367
+ if (taskHistoryStates.get(identity) !== state) return;
368
+ if (result.ok && result.body) mergeTaskHistoryPage(state, result.body, Boolean(state.nextBefore));
369
+ else state.error = summarizeFetchError(result);
370
+ } finally {
371
+ state.loadingOlder = false;
372
+ if (runsEl.dataset.historyIdentity === identity && taskHistoryStates.get(identity) === state) renderTaskHistory(runsEl, state, featureId, taskId, identity);
354
373
  }
355
374
  }
356
375
 
@@ -1,6 +1,56 @@
1
1
  import { writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { checkBudgetPreNode, createBudgetLedger, formatBudgetReportMarkdown, isHardBudgetBreached, recordNodeBudgetSample, skipPendingNodesForBudgetBreach, } from "../../application/evaluation/budget.js";
3
+ import { checkBudgetPreNode, createBudgetLedger, formatBudgetReportMarkdown, isHardBudgetBreached, recordNodeBudgetSample, reserveProviderBudget, skipPendingNodesForBudgetBreach, } from "../../application/evaluation/budget.js";
4
+ import { sha256OfCanonicalJson } from "../../task/contract/hash.js";
5
+ /** A continuation already contains its ancestors' consumption. Never sum both. */
6
+ export function validateCumulativeBudgetSuccessor(parent, child) {
7
+ const lineage = child.continuation?.budgetLineage ?? child.frontendRecoveryState?.budgetLineage;
8
+ const previous = parent.budgetLedger;
9
+ const next = child.budgetLedger;
10
+ if (!previous || !next || !lineage || (child.continuation?.parentRunId ?? child.frontendRecoveryState?.parentRunId) !== parent.runId || lineage.parentLedgerSha256 !== sha256OfCanonicalJson(previous) || sha256OfCanonicalJson(lineage.inheritedConsumed) !== sha256OfCanonicalJson(previous.consumed) || sha256OfCanonicalJson(parent.budget ?? null) !== sha256OfCanonicalJson(child.budget ?? null))
11
+ throw Error("FRONTEND_PROVIDER_BUDGET_LINEAGE_INVALID: unbound cumulative continuation");
12
+ for (const key of ["executorCalls", "providerRequests", "wallTimeMs", "repairPasses", "peakContextChars"])
13
+ if ((next.consumed[key] ?? 0) < (previous.consumed[key] ?? 0))
14
+ throw Error("FRONTEND_PROVIDER_BUDGET_LINEAGE_INVALID: consumption decreased");
15
+ return next;
16
+ }
17
+ const requestQueues = new WeakMap();
18
+ /** One shared, attempt-fenced, durable admission queue for every request in a run. */
19
+ export async function reserveDagProviderRequest(input) {
20
+ const ledger = input.state.budgetLedger;
21
+ if (!ledger || ledger.limits.maxProviderRequests === undefined)
22
+ throw Error("FRONTEND_PROVIDER_BUDGET_INVALID: frozen request budget unavailable");
23
+ const previous = requestQueues.get(ledger) ?? Promise.resolve();
24
+ const current = previous.catch(() => { }).then(async () => {
25
+ const active = () => input.state.status === "running" && input.state.nodes[input.nodeId]?.status === "RUNNING" && input.state.nodes[input.nodeId]?.currentAttempt === input.attempt;
26
+ if (!active())
27
+ throw Error("FRONTEND_PROVIDER_BUDGET_STALE: request belongs to an inactive attempt");
28
+ if (isHardBudgetBreached(ledger))
29
+ throw Error("FRONTEND_PROVIDER_BUDGET_EXHAUSTED: run budget already breached");
30
+ const breach = reserveProviderBudget(ledger, input.nodeId);
31
+ if (breach)
32
+ applyHardBudgetBreach(input.state, breach);
33
+ try {
34
+ await input.persist();
35
+ }
36
+ catch (error) {
37
+ ledger.status = "breached";
38
+ throw Error(`FRONTEND_PROVIDER_BUDGET_PERSIST_FAILED: ${error instanceof Error ? error.message : String(error)}`);
39
+ }
40
+ if (breach)
41
+ throw Error("FRONTEND_PROVIDER_BUDGET_EXHAUSTED: request quota reached");
42
+ if (!active())
43
+ throw Error("FRONTEND_PROVIDER_BUDGET_STALE: attempt stopped while reserving request");
44
+ });
45
+ requestQueues.set(ledger, current);
46
+ try {
47
+ await current;
48
+ }
49
+ finally {
50
+ if (requestQueues.get(ledger) === current)
51
+ requestQueues.delete(ledger);
52
+ }
53
+ }
4
54
  export function initRunBudgetLedger(state, budget) {
5
55
  if (!budget)
6
56
  return;
@@ -8,7 +58,7 @@ export function initRunBudgetLedger(state, budget) {
8
58
  state.budgetLedger = createBudgetLedger(budget);
9
59
  }
10
60
  function wallTimeMs(state, now = Date.now()) {
11
- const lineage = state.continuation?.budgetLineage;
61
+ const lineage = state.continuation?.budgetLineage ?? state.frontendRecoveryState?.budgetLineage;
12
62
  if (lineage) {
13
63
  return (lineage.inheritedConsumed.wallTimeMs +
14
64
  Math.max(0, now - new Date(lineage.inheritedAt).getTime()));
@@ -16,7 +66,7 @@ function wallTimeMs(state, now = Date.now()) {
16
66
  return Math.max(0, now - new Date(state.startedAt).getTime());
17
67
  }
18
68
  function repairPasses(state) {
19
- const lineage = state.continuation?.budgetLineage;
69
+ const lineage = state.continuation?.budgetLineage ?? state.frontendRecoveryState?.budgetLineage;
20
70
  if (lineage) {
21
71
  return (lineage.inheritedConsumed.repairPasses +
22
72
  Math.max(0, (state.convergence?.currentPass ?? 1) - 1));
@@ -0,0 +1,193 @@
1
+ import { mkdir, open, rename, rm } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import path from "node:path";
4
+ import { Check, Errors } from "typebox/value";
5
+ import { sha256OfCanonicalJson } from "../../task/contract/hash.js";
6
+ import { assertHarnessWriteAllowed } from "../../infrastructure/harness/completed-facts-guard.js";
7
+ import { readTypedEventStoreFromJsonl, typedEventPayloadSha256, hasCommittedTerminalEvent } from "./frontend-typed-event-store.js";
8
+ const queues = new Map();
9
+ const progressByTool = new WeakMap();
10
+ /** Process-local telemetry only; durable facts remain the authority. */
11
+ export function readFrontendToolProgress(tools) {
12
+ const counters = [...new Set((tools ?? []).flatMap(tool => tool && typeof tool === "object" && progressByTool.has(tool) ? [progressByTool.get(tool)] : []))];
13
+ if (!counters.length)
14
+ return undefined;
15
+ return counters.reduce((sum, item) => ({ failedRecords: sum.failedRecords + item.failedRecords, duplicateRecords: sum.duplicateRecords + item.duplicateRecords, durableSubmissions: sum.durableSubmissions + item.durableSubmissions, durableReplacements: sum.durableReplacements + item.durableReplacements }), { failedRecords: 0, duplicateRecords: 0, durableSubmissions: 0, durableReplacements: 0 });
16
+ }
17
+ async function exclusive(key, work) {
18
+ const previous = queues.get(key) ?? Promise.resolve();
19
+ const current = previous.catch(() => { }).then(work);
20
+ queues.set(key, current);
21
+ try {
22
+ return await current;
23
+ }
24
+ finally {
25
+ if (queues.get(key) === current)
26
+ queues.delete(key);
27
+ }
28
+ }
29
+ const hash = (value) => sha256OfCanonicalJson(JSON.parse(JSON.stringify(value, (_key, v) => v instanceof Map ? [...v.entries()].sort(([a], [b]) => String(a).localeCompare(String(b))) : v)));
30
+ const ledgerError = (code, message) => Object.assign(new Error(message), { code });
31
+ const failure = (code, error) => ({ content: [{ type: "text", text: JSON.stringify({ ok: false, code, error }) }], details: { ok: false, code, error } });
32
+ /** A failed rename must never fall back to copying over a live ledger. */
33
+ async function persist(file, records) {
34
+ assertHarnessWriteAllowed(file);
35
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
36
+ const temporary = `${file}.${randomUUID()}.tmp`;
37
+ try {
38
+ const handle = await open(temporary, "wx", 0o600);
39
+ try {
40
+ await handle.writeFile(records.map(r => JSON.stringify(r)).join("\n") + (records.length ? "\n" : ""));
41
+ await handle.sync();
42
+ }
43
+ finally {
44
+ await handle.close();
45
+ }
46
+ await rename(temporary, file);
47
+ }
48
+ finally {
49
+ await rm(temporary, { force: true }).catch(() => { });
50
+ }
51
+ }
52
+ /** One queue covers schema checks, semantic checks, all facts in a tool call,
53
+ * atomic persistence, and acknowledgement. The model never sees a draft as saved. */
54
+ export async function createDurableFrontendTools(input) {
55
+ const file = path.resolve(input.file);
56
+ const tools = input.tools.map(tool => {
57
+ const schema = tool.parameters;
58
+ return { ...tool, parameters: { ...schema, properties: { ...schema.properties, replace: { type: "boolean", description: "Explicitly revise the current fact with the same identity. Omit for new facts or identical replay." } } } };
59
+ });
60
+ const bindingSha256 = hash({ version: 1, attemptId: input.attemptId, binding: input.binding, schemas: tools.map(t => ({ name: t.name, parameters: t.parameters })) });
61
+ const publish = (store) => { input.store.records = store.records; input.store.revision = store.revision; input.setWorkingStore(input.store); };
62
+ const validate = async (records) => {
63
+ let revision = 0;
64
+ let terminal = false;
65
+ const ids = new Set();
66
+ const requests = new Set();
67
+ for (const r of records) {
68
+ if (r.attemptId !== input.attemptId || r.submission?.bindingSha256 !== bindingSha256)
69
+ throw ledgerError("FRONTEND_LEDGER_BINDING_MISMATCH", "frontend ledger binding/scope mismatch; preserve evidence and restart its owning phase");
70
+ if (r.phase !== "committed" || r.payloadSha256 !== typedEventPayloadSha256(r.fact) || r.revision <= revision || ids.has(r.eventId) || requests.has(r.requestId) || terminal)
71
+ throw ledgerError("FRONTEND_LEDGER_INTEGRITY_INVALID", "frontend ledger integrity/revision mismatch");
72
+ ids.add(r.eventId);
73
+ requests.add(r.requestId);
74
+ revision = r.revision;
75
+ terminal = hasCommittedTerminalEvent({ records: [r], revision }, input.attemptId);
76
+ }
77
+ await input.validateRestored?.(records);
78
+ };
79
+ const restore = async () => {
80
+ const records = await readTypedEventStoreFromJsonl(file);
81
+ if (records.length) {
82
+ await validate(records);
83
+ publish({ records, revision: records.at(-1).revision });
84
+ }
85
+ else if (input.store.records.some(r => r.submission))
86
+ throw ledgerError("FRONTEND_LEDGER_MISSING", "frontend durable ledger disappeared or became empty; refusing an unproven acknowledgement");
87
+ };
88
+ await exclusive(file, restore);
89
+ const progress = { failedRecords: 0, duplicateRecords: 0, durableSubmissions: 0, durableReplacements: 0 };
90
+ const replay = (receipt) => { progress.duplicateRecords++; return receipt; };
91
+ const wrapped = tools.map(tool => ({ ...tool, execute: (...args) => exclusive(file, async () => {
92
+ const [callId, params] = args;
93
+ const valid = Check(tool.parameters, params);
94
+ if (!valid) {
95
+ const errors = [...Errors(tool.parameters, params)];
96
+ return failure("TOOL_SCHEMA_INVALID", `${tool.name}: ${errors.slice(0, 8).map(e => `${e.instancePath || "/"}: ${e.message}`).join("; ")}${errors.length > 8 ? `; ${errors.length - 8} more fields` : ""}`);
97
+ }
98
+ try {
99
+ await restore();
100
+ const paramsSha256 = hash(params);
101
+ const singleton = new Set(["record_route_selection", "record_state_registry", "record_mock_api", "record_data_flow", "record_dependency", "record_design_deviation", "record_required_deliverables", "record_target_surface", "finalize_contract", "finalize_plan"]);
102
+ const naturalId = params?.id ?? params?.scopeId ?? params?.entry?.id ?? params?.requirementId ?? params?.choice?.purpose ?? (tool.name === "record_openspec_selection" ? params?.path : undefined) ?? (params?.endpoint ? `${params.endpoint.method}:${params.endpoint.path}` : undefined) ?? (singleton.has(tool.name) ? "singleton" : undefined);
103
+ const identity = `${tool.name}:${typeof naturalId === "string" ? naturalId : "incremental"}`;
104
+ const submissions = input.store.records.flatMap(r => r.submission?.receipt ? [r.submission] : []);
105
+ const previousCall = submissions.find(s => s.callId === callId);
106
+ if (previousCall)
107
+ return previousCall.tool === tool.name && previousCall.paramsSha256 === paramsSha256 ? replay(previousCall.receipt) : failure("REQUEST_ID_REUSE_CONFLICT", `${tool.name}: call id already acknowledged with different parameters`);
108
+ const previousFact = submissions.filter(s => s.identity === identity).at(-1);
109
+ if (tool.name !== "record_plan_group_coverage" && previousFact?.paramsSha256 === paramsSha256)
110
+ return replay(previousFact.receipt);
111
+ if (previousFact && naturalId !== undefined && params?.replace !== true && !["record_evidence_expectation", "record_required_deliverables", "record_data_flow"].includes(tool.name))
112
+ return failure("FACT_IDENTITY_CONFLICT", `${identity}: changing a saved fact requires explicit replacement (replace:true)`);
113
+ if (input.store.records.some(r => ["contract-finalized", "finalize_plan", "approve_review", "request_review_changes", "approve_design", "request_design_changes"].includes(String(r.fact.kind))))
114
+ return failure("TERMINAL_CONFLICT", "A successful terminal is already durable; only identical acknowledged submissions may be replayed");
115
+ const draft = structuredClone(input.store);
116
+ input.setWorkingStore(draft);
117
+ let result;
118
+ try {
119
+ // Protocol controls must not be spread into domain facts by permissive callers.
120
+ const original = input.tools.find(t => t.name === tool.name);
121
+ const domainParams = { ...params };
122
+ if (!original.parameters.properties?.replace)
123
+ delete domainParams.replace;
124
+ result = await tool.execute(args[0], domainParams, ...args.slice(2));
125
+ }
126
+ finally {
127
+ input.setWorkingStore(input.store);
128
+ }
129
+ if (result.details?.ok !== true)
130
+ return result;
131
+ const newRecords = draft.records.filter(r => r.phase === "committed" && r.revision > input.store.revision);
132
+ if (!newRecords.length)
133
+ return replay(result);
134
+ for (const record of draft.records) {
135
+ record.submission ??= { version: 1, bindingSha256, tool: "bootstrap", callId: record.requestId, identity: record.eventId, paramsSha256: record.payloadSha256 };
136
+ }
137
+ for (const record of newRecords)
138
+ record.submission = { version: 1, bindingSha256, tool: tool.name, callId, identity, paramsSha256 };
139
+ newRecords.at(-1).submission.receipt = JSON.parse(JSON.stringify(result));
140
+ const committed = draft.records.filter(r => r.phase === "committed");
141
+ await validate(committed);
142
+ await persist(file, committed);
143
+ publish(draft);
144
+ progress.durableSubmissions++;
145
+ if (previousFact)
146
+ progress.durableReplacements++;
147
+ return result;
148
+ }
149
+ catch (error) {
150
+ input.setWorkingStore(input.store);
151
+ return failure(typeof error?.code === "string" ? error.code : "FRONTEND_COMMIT_FAILED", error instanceof Error ? error.message : String(error));
152
+ }
153
+ }).then(result => { if (result.details?.ok !== true)
154
+ progress.failedRecords++; return result; }) }));
155
+ for (const tool of wrapped)
156
+ progressByTool.set(tool, progress);
157
+ return {
158
+ customTools: wrapped,
159
+ commitExternal: (work) => exclusive(file, async () => {
160
+ await restore();
161
+ const draft = structuredClone(input.store);
162
+ input.setWorkingStore(draft);
163
+ try {
164
+ await work();
165
+ const newRecords = draft.records.filter(r => r.phase === "committed" && r.revision > input.store.revision);
166
+ for (const record of draft.records) {
167
+ record.submission ??= {
168
+ version: 1,
169
+ bindingSha256,
170
+ tool: "external-adoption",
171
+ callId: record.requestId,
172
+ identity: record.eventId,
173
+ paramsSha256: record.payloadSha256,
174
+ };
175
+ }
176
+ const committed = draft.records.filter(r => r.phase === "committed");
177
+ await validate(committed);
178
+ await persist(file, committed);
179
+ publish(draft);
180
+ progress.durableSubmissions += newRecords.length;
181
+ }
182
+ finally {
183
+ input.setWorkingStore(input.store);
184
+ }
185
+ }),
186
+ flush: () => exclusive(file, async () => {
187
+ await restore();
188
+ const committed = input.store.records.filter(r => r.phase === "committed");
189
+ await validate(committed);
190
+ await persist(file, committed);
191
+ }),
192
+ };
193
+ }
@@ -0,0 +1,24 @@
1
+ import { z } from "zod";
2
+ /** Model-authored execution ownership; never replaces a source obligation. */
3
+ export const frontendExecutionSchema = z.object({
4
+ groupId: z.string().trim().min(1),
5
+ kind: z.enum(["behavior", "constraint", "exclusion"]),
6
+ summary: z.string().trim().min(1),
7
+ }).strict();
8
+ /** Latest canonical member wins; joining a group requires consistent metadata.
9
+ * No lexical/semantic similarity heuristic can erase a source ID or condition. */
10
+ export function collectFrontendExecutionGroups(units) {
11
+ const groups = new Map();
12
+ for (const unit of new Map(units.map(unit => [unit.id, unit])).values()) {
13
+ const execution = unit.execution === undefined ? undefined : frontendExecutionSchema.parse(unit.execution);
14
+ const key = execution ? `group:${execution.groupId}` : `requirement:${unit.id}`;
15
+ const previous = groups.get(key);
16
+ if (previous && execution && (previous.kind !== execution.kind || previous.summary !== execution.summary))
17
+ throw Error(`EXECUTION_GROUP_CONFLICT: ${execution.groupId}; preserve differing permissions, thresholds and error paths as separate obligations/groups`);
18
+ if (previous)
19
+ previous.requirementIds.push(unit.id);
20
+ else
21
+ groups.set(key, { id: execution?.groupId ?? unit.id, kind: execution?.kind ?? "unclassified", summary: execution?.summary ?? `Independent requirement ${unit.id}`, requirementIds: [unit.id] });
22
+ }
23
+ return [...groups.values()];
24
+ }
@@ -1,3 +1,4 @@
1
+ import { frontendExecutionSchema, collectFrontendExecutionGroups } from "./frontend-execution-groups.js";
1
2
  import { createHash } from "node:crypto";
2
3
  import { readFileSync } from "node:fs";
3
4
  import { readFile, stat } from "node:fs/promises";
@@ -711,6 +712,7 @@ export const frontendImplementationContractSchema = z
711
712
  .object({
712
713
  id,
713
714
  expectedOutcome: z.string().min(1),
715
+ execution: frontendExecutionSchema.optional(),
714
716
  implementationTargets: z.array(safePath),
715
717
  verificationTargetIds: z.array(z.string().min(1)),
716
718
  evidenceGap: gap.optional(),
@@ -806,6 +808,12 @@ export const frontendImplementationContractSchema = z
806
808
  })
807
809
  .strict()
808
810
  .superRefine((value, ctx) => {
811
+ try {
812
+ collectFrontendExecutionGroups(value.requirements);
813
+ }
814
+ catch (error) {
815
+ ctx.addIssue({ code: "custom", path: ["requirements"], message: String(error) });
816
+ }
809
817
  const stableIdentities = [
810
818
  {
811
819
  path: "requirements",
@@ -2063,6 +2071,7 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
2063
2071
  ? { sourceFragmentIds: req.sourceFragmentIds }
2064
2072
  : {}),
2065
2073
  ...(Array.isArray(req.sourceRefs) ? { sourceRefs: req.sourceRefs } : {}),
2074
+ ...(req.execution !== undefined ? { execution: req.execution } : {}),
2066
2075
  };
2067
2076
  // Do not mark free-form realIntegrationGap as blocking; defer to FE-TEST/FINAL-VERIFY.
2068
2077
  if (gapText) {
@@ -0,0 +1,76 @@
1
+ import { sha256OfCanonicalJson } from "../../task/contract/hash.js";
2
+ /** An execution packing target, not a claim about any model's context limit. */
3
+ export const FRONTEND_SCOPE_TARGET_BYTES = 24_000;
4
+ export function packFrontendInputUnits(units, options = {}) {
5
+ const target = options.targetBytes ?? FRONTEND_SCOPE_TARGET_BYTES;
6
+ const maxUnits = options.maxUnits ?? 4;
7
+ if (!Number.isFinite(target) || target <= 0 || !Number.isInteger(maxUnits) || maxUnits < 1)
8
+ throw Error("FRONTEND_INPUT_POLICY_INVALID");
9
+ const batches = [];
10
+ let batch = [];
11
+ let bytes = 2;
12
+ let cost = 0;
13
+ const ids = new Set();
14
+ for (const unit of units) {
15
+ if (!unit.id || ids.has(unit.id))
16
+ throw Error(`FRONTEND_INPUT_ID_CONFLICT: ${unit.id}`);
17
+ ids.add(unit.id);
18
+ const size = Buffer.byteLength(JSON.stringify(unit), "utf8") + 1;
19
+ if (options.maxUnitBytes !== undefined && size > options.maxUnitBytes)
20
+ throw Error(`FRONTEND_INPUT_UNIT_TOO_LARGE: ${unit.id} requires ${size} bytes; split the source obligation without dropping conditions`);
21
+ const unitCost = options.cost?.(unit) ?? 1;
22
+ if (batch.length && (batch.length >= maxUnits || bytes + size > target || cost + unitCost > (options.maxCost ?? Infinity))) {
23
+ batches.push(batch);
24
+ batch = [];
25
+ bytes = 2;
26
+ cost = 0;
27
+ }
28
+ batch.push(unit);
29
+ bytes += size;
30
+ cost += unitCost;
31
+ }
32
+ if (batch.length)
33
+ batches.push(batch);
34
+ return batches;
35
+ }
36
+ /** Select whole obligations before serialization; never clip identities or text. */
37
+ export function projectFrontendInputScope(payload, scopeIds) {
38
+ const byId = new Map(payload.requirements.map(r => [r.id, r]));
39
+ const requirements = [...new Set(scopeIds)].map(id => {
40
+ const unit = byId.get(id);
41
+ if (!unit)
42
+ throw Error(`FRONTEND_INPUT_SCOPE_MISSING: ${id}`);
43
+ return unit;
44
+ });
45
+ const fragments = new Set(requirements.flatMap(r => Array.isArray(r.sourceFragmentIds) ? r.sourceFragmentIds : []));
46
+ const inScope = (fact) => {
47
+ const ids = typeof fact.requirementId === "string" ? [fact.requirementId] : Array.isArray(fact.requirementIds) ? fact.requirementIds : [];
48
+ return !ids.length || ids.some(id => scopeIds.includes(String(id)));
49
+ };
50
+ return {
51
+ ...payload, requirements,
52
+ ...(Array.isArray(payload.sharedFacts) ? { sharedFacts: payload.sharedFacts.filter(f => f && typeof f === "object" && inScope(f)).map(f => f.kind === "required-deliverables" && Array.isArray(f.items) ? { ...f, items: f.items.filter((item) => inScope(item)) } : f) } : {}),
53
+ ...(Array.isArray(payload.fragments) ? { fragments: payload.fragments.filter(f => f && typeof f === "object" && fragments.has(f.id)) } : {}),
54
+ inputManifest: { schemaVersion: 1, semantics: "full", sourceSha256: sha256OfCanonicalJson(payload), scopeIds: requirements.map(r => r.id), excludedIds: payload.requirements.filter(r => !scopeIds.includes(r.id)).map(r => r.id) },
55
+ };
56
+ }
57
+ export function parseFrontendInputBlock(prompt, phase) {
58
+ const block = new RegExp(`<frontend_${phase}_input>[\\s\\S]*?<\\/frontend_${phase}_input>`).exec(prompt)?.[0];
59
+ if (!block)
60
+ return undefined;
61
+ for (const line of block.split(/\r?\n/)) {
62
+ try {
63
+ const payload = JSON.parse(line);
64
+ if (Array.isArray(payload.requirements))
65
+ return { block, payload };
66
+ }
67
+ catch { /* wrapper prose is not JSON */ }
68
+ }
69
+ throw Error(`FRONTEND_INPUT_INVALID: ${phase} compiled inventory is not parseable`);
70
+ }
71
+ export function projectFrontendContractPrompt(prompt, scopeIds) {
72
+ const input = parseFrontendInputBlock(prompt, "contract");
73
+ if (!input)
74
+ throw Error("FRONTEND_INPUT_MISSING: contract compiled inventory unavailable");
75
+ return prompt.replace(input.block, ["<frontend_contract_input>", "Complete canonical obligations for this session only. Confirm each id with record_requirement; preserve all independent conditions.", JSON.stringify(projectFrontendInputScope(input.payload, scopeIds)), "</frontend_contract_input>"].join("\n"));
76
+ }