@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.
- package/AGENTS.md +2 -0
- package/CHANGELOG.md +20 -0
- package/bin/loop-agent.js +37 -1
- package/dist/build-stamp.json +6 -0
- package/dist/cli/program.js +2 -2
- package/dist/shared/package-metadata.js +32 -0
- package/dist/worker/loop-agent/loop-agent-client.js +13 -0
- package/dist/workflows/dag/contract-output-registry.js +14 -0
- package/dist/workflows/dag/contract-validator-registrations.js +8 -0
- package/dist/workflows/dag/dynamic-runtime/shared.js +9 -1
- package/dist/workflows/dag/frontend-implementation-contract.js +233 -39
- package/dist/workflows/dag/frontend-prewrite-gate.js +364 -61
- package/dist/workflows/dag/frontend-repair.js +219 -18
- package/dist/workflows/dag/frontend-verification-trace.js +47 -32
- package/dist/workflows/dag/init-hybrid.js +41 -24
- package/dist/workflows/dag/node-execution.js +89 -0
- package/dist/workflows/dag/runner.js +52 -3
- package/dist/workflows/dag/scheduler.js +98 -3
- package/dist/workflows/dag/types.js +23 -2
- package/package.json +2 -2
|
@@ -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 =
|
|
280
|
+
const pauseGateRunnable = runnableAfterAdmission.filter((id) => {
|
|
186
281
|
const task = input.tasksById.get(id);
|
|
187
282
|
return isPauseOnHumanDecisionGate(task);
|
|
188
283
|
});
|
|
189
|
-
const regularRunnable =
|
|
190
|
-
const rankWriterNodeIds =
|
|
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
|
-
|
|
658
|
-
|
|
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.
|
|
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",
|