@tea-agent/loop-agent 0.35.0 → 0.35.1-beta.1

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.
@@ -21,7 +21,7 @@ import { createSkillSnapshot, prepareSkillSnapshotForContinuation, writeSkillSna
21
21
  import { captureWorkspaceCheckpoint, WORKSPACE_CHECKPOINT_START_REL, WORKSPACE_CHECKPOINT_TERMINAL_REL, writeWorkspaceCheckpoint, } from "./workspace-checkpoint.js";
22
22
  import { buildNodePrompt, buildNodePromptWithResolvedSkillInstructions, executeDagNode, } from "./node-execution.js";
23
23
  import { runConvergencePassController, } from "./convergence/controller.js";
24
- import { executeDagRanksOnce, isConditionSkippedReason } from "./scheduler.js";
24
+ import { executeDagRanksOnce, FRONTEND_WRITER_NODE_IDS, isConditionSkippedReason, readFrontendPrewriteResult, } from "./scheduler.js";
25
25
  import { topoSortToRanks } from "./topo.js";
26
26
  import { parseDagSpec, resolveModelForTask, } from "./types.js";
27
27
  import { executeDynamicCondition } from "./dynamic-runtime/condition.js";
@@ -448,6 +448,39 @@ async function executeDagCheckpoint(input) {
448
448
  void persistState().catch(() => { });
449
449
  }, runnerLivenessPolicy.heartbeatIntervalMs);
450
450
  heartbeatTimer.unref();
451
+ // Graceful terminal persistence: an outer SIGTERM/SIGINT (operator hard
452
+ // timeout, supervision layer, or shell wall-clock) must not leave the run
453
+ // orphaned in RUNNING with a dead heartbeat. Persist a terminal failed
454
+ // state with an explicit terminalReason before exiting so downstream
455
+ // liveness/doctor tooling sees a diagnosable terminal instead of an orphan.
456
+ let terminalSignal;
457
+ const shutdownOnSignal = (signal) => {
458
+ if (terminalSignal)
459
+ return;
460
+ terminalSignal = signal;
461
+ clearInterval(heartbeatTimer);
462
+ if (state.runner)
463
+ state.runner.heartbeatAt = new Date().toISOString();
464
+ state.status = "failed";
465
+ state.finishedAt = new Date().toISOString();
466
+ state.terminalReason = `runner terminated by ${signal} before run completion`;
467
+ console.warn(`[run-dag] received ${signal}; persisting terminal state and exiting`);
468
+ const exit = () => process.exit(signal === "SIGINT" ? 130 : 143);
469
+ // Best-effort atomic persist with a hard exit deadline so a stuck write
470
+ // queue cannot keep the process alive past the supervisor's kill window.
471
+ const hardExit = setTimeout(exit, 5000);
472
+ hardExit.unref();
473
+ void persistState()
474
+ .catch(() => { })
475
+ .finally(() => {
476
+ clearTimeout(hardExit);
477
+ exit();
478
+ });
479
+ };
480
+ const onSigTerm = () => shutdownOnSignal("SIGTERM");
481
+ const onSigInt = () => shutdownOnSignal("SIGINT");
482
+ process.once("SIGTERM", onSigTerm);
483
+ process.once("SIGINT", onSigInt);
451
484
  try {
452
485
  const tasksById = new Map(spec.tasks.map((task) => [task.id, task]));
453
486
  const baseExecuteNode = input.executeNode ??
@@ -504,6 +537,7 @@ async function executeDagCheckpoint(input) {
504
537
  tasksById,
505
538
  maxConcurrent,
506
539
  persistState,
540
+ runDir,
507
541
  abortSignal: input.abortSignal,
508
542
  createExecuteNodeForRank: (rankWriterNodeIds) => buildRankAwareExecuteNode({
509
543
  baseExecuteNode,
@@ -595,7 +629,7 @@ async function executeDagCheckpoint(input) {
595
629
  runDir = await moveToPausedRunDir(runDir, pausedRunDir);
596
630
  }
597
631
  else {
598
- finalizeTerminalRunStatus(state, spec.tasks.length);
632
+ await finalizeTerminalRunStatus(state, spec.tasks.length, runDir);
599
633
  await persistState();
600
634
  await notifyRunObserver(input.observer, "onRunFinish", state);
601
635
  try {
@@ -628,6 +662,8 @@ async function executeDagCheckpoint(input) {
628
662
  }
629
663
  finally {
630
664
  clearInterval(heartbeatTimer);
665
+ process.removeListener("SIGTERM", onSigTerm);
666
+ process.removeListener("SIGINT", onSigInt);
631
667
  }
632
668
  }
633
669
  async function notifyRunObserver(observer, event, state) {
@@ -695,7 +731,20 @@ function isSuccessfulConvergenceTerminal(state) {
695
731
  return true;
696
732
  return SUCCESSFUL_CONVERGENCE_TERMINAL_REASONS.has(reason);
697
733
  }
698
- function finalizeTerminalRunStatus(state, taskCount) {
734
+ export async function finalizeTerminalRunStatus(state, taskCount, runDir) {
735
+ // Terminal override: a denied frontend writer must never surface as a
736
+ // mechanical partial_failed just because prewrite FINISHED while the writer
737
+ // was SKIPPED. Fail closed on retryable-invalid/blocked before aggregation.
738
+ const hasFrontendWriter = Object.keys(state.nodes).some((id) => FRONTEND_WRITER_NODE_IDS.includes(id));
739
+ if (hasFrontendWriter) {
740
+ const admission = await readFrontendPrewriteResult(runDir);
741
+ if (admission.ok &&
742
+ (admission.result.classification === "retryable-invalid" ||
743
+ admission.result.classification === "blocked")) {
744
+ state.status = "failed";
745
+ return;
746
+ }
747
+ }
699
748
  const finishedCount = Object.values(state.nodes).filter((n) => n.status === "FINISHED").length;
700
749
  const { supersededIds, supersededFailures } = collectSupersededIntermediateFailures(state);
701
750
  if (state.convergence && supersededFailures.length > 0) {
@@ -1,5 +1,9 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
1
3
  import { isPauseOnHumanDecisionGate } from "./decision-envelope.js";
2
4
  import { evaluateConditionExpression } from "./dynamic-runtime/condition.js";
5
+ import { sha256Hex } from "./frontend-implementation-contract.js";
6
+ import { frontendPrewriteResultV1Schema, FRONTEND_PREWRITE_RESULT_ARTIFACT_NAME, } from "./frontend-prewrite-gate.js";
3
7
  export function isConditionSkippedReason(reason) {
4
8
  return Boolean(reason?.startsWith("condition "));
5
9
  }
@@ -104,6 +108,58 @@ async function mapConcurrent(items, limit, fn) {
104
108
  }
105
109
  await Promise.all(executing);
106
110
  }
111
+ export const FRONTEND_WRITER_NODE_IDS = [
112
+ "frontend-implement-pi",
113
+ "frontend-repair-pi",
114
+ ];
115
+ /** Run-relative artifact location written by the prewrite gate generator. */
116
+ export const FRONTEND_PREWRITE_RESULT_SOURCE_ARTIFACT = path.posix.join("contracts", FRONTEND_PREWRITE_RESULT_ARTIFACT_NAME);
117
+ /**
118
+ * Read and validate the frontend-prewrite-result-v1 artifact. Fail-closed:
119
+ * a missing file or invalid payload returns ok:false and never throws.
120
+ */
121
+ export async function readFrontendPrewriteResult(runDir) {
122
+ const artifactPath = path.join(runDir, "contracts", FRONTEND_PREWRITE_RESULT_ARTIFACT_NAME);
123
+ let raw;
124
+ try {
125
+ raw = await readFile(artifactPath, "utf8");
126
+ }
127
+ catch (error) {
128
+ if (error.code === "ENOENT") {
129
+ return {
130
+ ok: false,
131
+ reason: `frontend prewrite result artifact missing: ${artifactPath}`,
132
+ };
133
+ }
134
+ throw error;
135
+ }
136
+ const artifactHash = sha256Hex(raw);
137
+ let parsed;
138
+ try {
139
+ parsed = JSON.parse(raw);
140
+ }
141
+ catch (error) {
142
+ return {
143
+ ok: false,
144
+ reason: `frontend prewrite result artifact is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
145
+ };
146
+ }
147
+ const result = frontendPrewriteResultV1Schema.safeParse(parsed);
148
+ if (!result.success) {
149
+ return {
150
+ ok: false,
151
+ reason: "frontend prewrite result artifact failed schema validation",
152
+ };
153
+ }
154
+ return { ok: true, result: result.data, artifactHash };
155
+ }
156
+ /** Authorize only accepted / accepted-normalized classifications. */
157
+ export function isFrontendWriterAuthorized(result) {
158
+ return result.classification === "accepted" ||
159
+ result.classification === "accepted-normalized"
160
+ ? "authorized"
161
+ : "denied";
162
+ }
107
163
  /** Fail-closed: leave no PENDING nodes that look "still scheduled" after abort. */
108
164
  export function markPendingNodesControllerInterrupted(state, reason = "run aborted by controller (abortSignal)") {
109
165
  const affected = [];
@@ -168,6 +224,45 @@ export async function executeDagRanksOnce(input) {
168
224
  await input.persistState();
169
225
  const conditionSkippedSet = new Set(conditionSettled);
170
226
  const actuallyRunnable = runnable.filter((id) => !conditionSkippedSet.has(id));
227
+ const frontendAdmissionSettled = [];
228
+ if (input.runDir) {
229
+ for (const id of actuallyRunnable) {
230
+ if (!FRONTEND_WRITER_NODE_IDS.includes(id))
231
+ continue;
232
+ const node = input.state.nodes[id];
233
+ const checkedAt = new Date().toISOString();
234
+ const admission = await readFrontendPrewriteResult(input.runDir);
235
+ if (!admission.ok) {
236
+ node.status = "SKIPPED";
237
+ node.skippedReason = "frontend-prewrite-not-authorized";
238
+ node.finishedAt = checkedAt;
239
+ frontendAdmissionSettled.push(id);
240
+ continue;
241
+ }
242
+ const decision = isFrontendWriterAuthorized(admission.result);
243
+ node.frontendWriterAdmission = {
244
+ schemaVersion: 1,
245
+ writerNodeId: id,
246
+ decision,
247
+ sourceArtifact: FRONTEND_PREWRITE_RESULT_SOURCE_ARTIFACT,
248
+ artifactHash: admission.artifactHash,
249
+ checkedAt,
250
+ reason: decision === "denied"
251
+ ? `classification: ${admission.result.classification}`
252
+ : null,
253
+ };
254
+ if (decision === "denied") {
255
+ node.status = "SKIPPED";
256
+ node.skippedReason = "frontend-prewrite-not-authorized";
257
+ node.finishedAt = checkedAt;
258
+ frontendAdmissionSettled.push(id);
259
+ }
260
+ }
261
+ if (frontendAdmissionSettled.length > 0)
262
+ await input.persistState();
263
+ }
264
+ const frontendAdmissionSkippedSet = new Set(frontendAdmissionSettled);
265
+ const runnableAfterAdmission = actuallyRunnable.filter((id) => !frontendAdmissionSkippedSet.has(id));
171
266
  const blocked = pending.filter((id) => {
172
267
  const task = input.tasksById.get(id);
173
268
  return (dependencyReadiness(task, input.state.nodes, input.tasksById) === "skip");
@@ -182,12 +277,12 @@ export async function executeDagRanksOnce(input) {
182
277
  if (blocked.length > 0) {
183
278
  await input.persistState();
184
279
  }
185
- const pauseGateRunnable = actuallyRunnable.filter((id) => {
280
+ const pauseGateRunnable = runnableAfterAdmission.filter((id) => {
186
281
  const task = input.tasksById.get(id);
187
282
  return isPauseOnHumanDecisionGate(task);
188
283
  });
189
- const regularRunnable = actuallyRunnable.filter((id) => !pauseGateRunnable.includes(id));
190
- const rankWriterNodeIds = actuallyRunnable.filter((id) => {
284
+ const regularRunnable = runnableAfterAdmission.filter((id) => !pauseGateRunnable.includes(id));
285
+ const rankWriterNodeIds = runnableAfterAdmission.filter((id) => {
191
286
  const task = input.tasksById.get(id);
192
287
  return (task?.executor === "pi" &&
193
288
  task.toolProfile === "write" &&
@@ -654,8 +654,15 @@ export const dagConvergenceSpecSchema = z
654
654
  chainNodeIds: z.array(z.string()).optional(),
655
655
  })
656
656
  .optional();
657
- export const dagTaskSchema = z.object({
658
- id: z.string().regex(/^[a-z][a-z0-9-]*$/, "task id must be kebab-case"),
657
+ /**
658
+ * Schema ids that may opt a node into producing-node structured contract
659
+ * self-validation. Validators are registered in the contract output registry
660
+ * (src/workflows/dag/contract-output-registry.ts).
661
+ */
662
+ export const structuredContractOutputSchemaIds = [
663
+ "frontend-implementation-contract-v1",
664
+ ];
665
+ export const dagTaskSchema = z.object({ id: z.string().regex(/^[a-z][a-z0-9-]*$/, "task id must be kebab-case"),
659
666
  depends_on: z.array(z.string()).default([]),
660
667
  /**
661
668
  * How SKIPPED upstreams affect readiness.
@@ -704,6 +711,20 @@ export const dagTaskSchema = z.object({
704
711
  * on safe read-only Pi nodes before the node becomes ERROR.
705
712
  */
706
713
  outputProtocol: dagOutputProtocolSchema.optional(),
714
+ /**
715
+ * Node-level structured contract self-check: after the Pi output is
716
+ * produced, validate it against the named contract schema at the node so
717
+ * schema/typo/null violations become invalid-output (retryable on the node)
718
+ * instead of failing later at a downstream deterministic gate. Validators
719
+ * are resolved via the contract output registry keyed by schemaId.
720
+ */
721
+ structuredContractOutput: z
722
+ .object({
723
+ schemaId: z.enum(structuredContractOutputSchemaIds),
724
+ retryOnInvalid: z.boolean().default(true),
725
+ })
726
+ .strict()
727
+ .optional(),
707
728
  /**
708
729
  * Fail-closed outcome/diff consistency contract for bounded Pi writers.
709
730
  * The writer must begin with IMPLEMENTATION_OUTCOME: changed,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.35.0",
3
+ "version": "0.35.1-beta.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",
@@ -45,7 +45,7 @@
45
45
  "pi-prompt": "node --import tsx/esm src/cli.ts pi-prompt",
46
46
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
47
47
  "brand:sync": "node scripts/sync-brand-assets.mjs",
48
- "build": "npm run brand:sync && npm run clean && tsc -p tsconfig.build.json && node -e \"const fs=require('node:fs');const p='dist/worker/observe/static';fs.mkdirSync(p,{recursive:true});fs.cpSync('src/worker/observe/static',p,{recursive:true});\" && npm run console:build",
48
+ "build": "npm run brand:sync && npm run clean && tsc -p tsconfig.build.json && node -e \"const fs=require('node:fs');const p='dist/worker/observe/static';fs.mkdirSync(p,{recursive:true});fs.cpSync('src/worker/observe/static',p,{recursive:true});\" && npm run console:build && node scripts/write-build-stamp.mjs",
49
49
  "console:typecheck": "tsc -p src/worker/console/tsconfig.json",
50
50
  "console:build": "npm run console:typecheck && vite build --config src/worker/console/vite.config.ts",
51
51
  "prepack": "npm run build",