@tea-agent/loop-agent 0.11.0 → 0.12.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/CHANGELOG.md +25 -0
- package/README.md +3 -2
- package/dist/application/dag/generate-task-dag.js +15 -0
- package/dist/application/dag/run-dag.js +10 -0
- package/dist/application/dag/validate-dag.js +11 -0
- package/dist/commands/init.js +74 -7
- package/dist/shared/package-metadata.js +135 -0
- package/dist/task/config-types.js +1 -0
- package/dist/worker/cli.js +3 -1
- package/dist/worker/observability/event-history.js +216 -0
- package/dist/worker/observability/read-model.js +312 -83
- package/dist/worker/observe/paths.js +17 -0
- package/dist/worker/observe/routes.js +165 -21
- package/dist/worker/observe/server.js +59 -1
- package/dist/worker/observe/static/api.js +27 -0
- package/dist/worker/observe/static/app.js +120 -2598
- package/dist/worker/observe/static/constants.js +148 -0
- package/dist/worker/observe/static/copy.js +67 -0
- package/dist/worker/observe/static/dag-helpers.js +172 -0
- package/dist/worker/observe/static/dag-model.js +72 -0
- package/dist/worker/observe/static/dom.js +61 -0
- package/dist/worker/observe/static/format-pool.js +67 -0
- package/dist/worker/observe/static/format.js +292 -0
- package/dist/worker/observe/static/index.html +300 -82
- package/dist/worker/observe/static/kpi.js +94 -0
- package/dist/worker/observe/static/relations.js +128 -0
- package/dist/worker/observe/static/router.js +85 -0
- package/dist/worker/observe/static/run-processing.js +148 -0
- package/dist/worker/observe/static/shell-chrome.js +68 -0
- package/dist/worker/observe/static/state.js +253 -0
- package/dist/worker/observe/static/styles.css +1719 -495
- package/dist/worker/observe/static/views/batch.js +226 -0
- package/dist/worker/observe/static/views/dag-graph.js +172 -0
- package/dist/worker/observe/static/views/dag-inspector.js +477 -0
- package/dist/worker/observe/static/views/dag.js +362 -0
- package/dist/worker/observe/static/views/dashboard.js +442 -0
- package/dist/worker/observe/static/views/failures.js +143 -0
- package/dist/worker/observe/static/views/feature.js +453 -0
- package/dist/worker/observe/static/views/pool.js +347 -0
- package/dist/worker/observe/static/views/run.js +453 -0
- package/dist/worker/observe/static/views/session-timeline.js +205 -0
- package/dist/worker/observe/static/views/shell.js +7 -0
- package/dist/worker/observe/static/views/task.js +260 -0
- package/dist/worker/observe/static/views/timeline.js +163 -0
- package/dist/workflows/dag/controller-identity.js +104 -0
- package/dist/workflows/dag/init-hybrid.js +396 -3
- package/dist/workflows/dag/node-execution.js +123 -29
- package/dist/workflows/dag/repair-artifact.js +91 -0
- package/dist/workflows/dag/report.js +50 -0
- package/dist/workflows/dag/retry-policy.js +138 -0
- package/dist/workflows/dag/runner.js +32 -0
- package/dist/workflows/dag/runtime-contract.js +87 -0
- package/dist/workflows/dag/skill-snapshot.js +2 -0
- package/dist/workflows/dag/types.js +44 -1
- package/dist/workflows/dag/validate.js +68 -4
- package/docs/agent-dag-runner.md +26 -1
- package/docs/architecture/dag-execution.md +6 -0
- package/docs/architecture/evolution.md +4 -3
- package/docs/architecture/facts-and-state.md +1 -1
- package/docs/design/README.md +4 -3
- package/docs/exec-plans/active/README.md +1 -3
- package/docs/exec-plans/completed/README.md +11 -0
- package/docs/feature-workflow.md +28 -0
- package/docs/progress/README.md +18 -0
- package/docs/reports/README.md +8 -2
- package/docs/templates/agent-dag-report.schema.json +17 -0
- package/docs/templates/agent-dag.schema.json +69 -1
- package/docs/templates/agent-dag.supervised-implementation.json +8 -2
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +139 -0
- package/docs/templates/backend-test-dag.json +276 -0
- package/docs/templates/backend-test-dag.retrospect.prompt.md +125 -0
- package/docs/templates/backend-test-dag.review-cases.prompt.md +81 -0
- package/package.json +1 -1
- package/skills/loop-agent/references/command-reference.md +1 -0
- package/skills/loop-agent/references/hybrid-dag.md +22 -3
- package/skills/loop-agent/references/verification-and-failure-handling.md +6 -0
|
@@ -123,6 +123,97 @@ export function validateRepairArtifactScope(input) {
|
|
|
123
123
|
}
|
|
124
124
|
return { ok: true, artifact };
|
|
125
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Return the list of governed-writer contract violations for a candidate repair
|
|
128
|
+
* node. An empty array means the node is a governed Pi writer eligible to
|
|
129
|
+
* receive a structured repair artifact.
|
|
130
|
+
*/
|
|
131
|
+
export function repairWriterContractIssues(task) {
|
|
132
|
+
const issues = [];
|
|
133
|
+
if (task.executor !== "pi") {
|
|
134
|
+
issues.push(`executor must be pi (got ${task.executor ?? "unset"})`);
|
|
135
|
+
}
|
|
136
|
+
if (task.toolProfile !== "write") {
|
|
137
|
+
issues.push(`toolProfile must be write (got ${task.toolProfile ?? "unset"})`);
|
|
138
|
+
}
|
|
139
|
+
if (task.writePolicy !== "exclusive") {
|
|
140
|
+
issues.push(`writePolicy must be exclusive (got ${task.writePolicy ?? "unset"})`);
|
|
141
|
+
}
|
|
142
|
+
const allowed = task.allowedPaths ?? [];
|
|
143
|
+
const writeSet = task.writeSet ?? [];
|
|
144
|
+
const forbidden = task.forbiddenPaths ?? [];
|
|
145
|
+
if (allowed.length === 0) {
|
|
146
|
+
issues.push("allowedPaths must be non-empty");
|
|
147
|
+
}
|
|
148
|
+
if (writeSet.length === 0) {
|
|
149
|
+
issues.push("writeSet must be non-empty");
|
|
150
|
+
}
|
|
151
|
+
for (const entry of writeSet) {
|
|
152
|
+
const normalized = normalizePath(entry);
|
|
153
|
+
if (forbidden.some((pattern) => pathMatchesPattern(normalized, pattern))) {
|
|
154
|
+
issues.push(`writeSet entry "${entry}" conflicts with forbiddenPaths`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return issues;
|
|
158
|
+
}
|
|
159
|
+
export function isGovernedRepairWriter(task) {
|
|
160
|
+
return repairWriterContractIssues(task).length === 0;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Resolve the repair writer a repair-artifact gate feeds, using the explicit
|
|
164
|
+
* `repairNodeId` reference when present and otherwise deriving a unique safe
|
|
165
|
+
* downstream Pi writer. Never guesses by node name.
|
|
166
|
+
*/
|
|
167
|
+
export function resolveRepairTaskForGate(input) {
|
|
168
|
+
const gate = input.gateTask.shell?.repairArtifactGate;
|
|
169
|
+
if (!gate) {
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
reason: `task ${input.gateTask.id} has no repairArtifactGate`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const directDownstream = input.tasks.filter((task) => task.depends_on.includes(input.gateTask.id));
|
|
176
|
+
if (gate.repairNodeId) {
|
|
177
|
+
const declared = input.tasks.find((task) => task.id === gate.repairNodeId);
|
|
178
|
+
if (!declared) {
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
reason: `repair artifact gate "${input.gateTask.id}" declares repairNodeId "${gate.repairNodeId}" but no task with that id exists`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (!declared.depends_on.includes(input.gateTask.id)) {
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
reason: `repair node "${declared.id}" declared by gate "${input.gateTask.id}" must depend_on the gate`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const issues = repairWriterContractIssues(declared);
|
|
191
|
+
if (issues.length > 0) {
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
reason: `repair node "${declared.id}" declared by gate "${input.gateTask.id}" is not a governed Pi writer: ${issues.join("; ")}`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return { ok: true, repairTask: declared };
|
|
198
|
+
}
|
|
199
|
+
const candidates = directDownstream.filter(isGovernedRepairWriter);
|
|
200
|
+
if (candidates.length === 1) {
|
|
201
|
+
return { ok: true, repairTask: candidates[0] };
|
|
202
|
+
}
|
|
203
|
+
const candidateIds = directDownstream.map((task) => task.id).join(", ") || "none";
|
|
204
|
+
if (candidates.length === 0) {
|
|
205
|
+
return {
|
|
206
|
+
ok: false,
|
|
207
|
+
reason: `repair artifact gate "${input.gateTask.id}" has no repairNodeId and no unique governed Pi writer among direct downstream nodes (${candidateIds}); regenerate the DAG with an explicit repairNodeId`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
reason: `repair artifact gate "${input.gateTask.id}" has no repairNodeId and multiple governed Pi writer candidates downstream (${candidates
|
|
213
|
+
.map((task) => task.id)
|
|
214
|
+
.join(", ")}); declare repairNodeId to disambiguate`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
126
217
|
export function formatRepairArtifactForPrompt(artifact) {
|
|
127
218
|
return [
|
|
128
219
|
"Repair artifact:",
|
|
@@ -10,6 +10,7 @@ import { dagNormalizedFailureCategorySchema, normalizeDagFailureCategory, } from
|
|
|
10
10
|
import { dagProductLineFailureCategoryValues, routeDagFailure, } from "./failure-routing.js";
|
|
11
11
|
import { DAG_RECOVERY_ACTIONS, planDagRecovery, } from "./recovery-recommendation.js";
|
|
12
12
|
import { dagNodeExecutorSchema, dagNodeStatusSchema, LEGACY_TOP_LEVEL_MODELS_ERROR, parseDagSpec, resolveModelForTask, } from "./types.js";
|
|
13
|
+
import { checkRuntimeContractCompatible, DAG_CONTROLLER_CAPABILITIES, } from "./runtime-contract.js";
|
|
13
14
|
export const DAG_REPORT_SCHEMA_VERSION = 1;
|
|
14
15
|
export const DAG_REPORT_SCHEMA_RELATIVE_PATH = "docs/templates/agent-dag-report.schema.json";
|
|
15
16
|
const dagRunLifecycleSchema = z.enum(["active", "paused", "completed"]);
|
|
@@ -169,6 +170,22 @@ const dagRunReportEntrySchema = z
|
|
|
169
170
|
convergence: dagConvergenceReportSchema.optional(),
|
|
170
171
|
pausedByNodeId: z.string().optional(),
|
|
171
172
|
pauseReason: z.string().optional(),
|
|
173
|
+
controllerIdentity: z
|
|
174
|
+
.object({
|
|
175
|
+
status: z.enum(["pinned", "legacy-unpinned"]),
|
|
176
|
+
capturedAt: z.string().min(1),
|
|
177
|
+
packageName: z.string().optional(),
|
|
178
|
+
packageVersion: z.string().optional(),
|
|
179
|
+
packageFingerprint: z.string().optional(),
|
|
180
|
+
binarySha256: z.string().optional(),
|
|
181
|
+
runtimeContractCompatibility: z.enum([
|
|
182
|
+
"compatible",
|
|
183
|
+
"incompatible",
|
|
184
|
+
"legacy-unspecified",
|
|
185
|
+
]),
|
|
186
|
+
runtimeContractReason: z.string().optional(),
|
|
187
|
+
})
|
|
188
|
+
.optional(),
|
|
172
189
|
executorJsonl: dagArtifactRefSchema,
|
|
173
190
|
nodes: z.array(dagNodeReportRowSchema),
|
|
174
191
|
})
|
|
@@ -291,6 +308,38 @@ function resolvePrimaryRecovery(run, primaryNode) {
|
|
|
291
308
|
autoRetryEligible: false,
|
|
292
309
|
};
|
|
293
310
|
}
|
|
311
|
+
function summarizeControllerIdentityForReport(ref, spec) {
|
|
312
|
+
if (!ref)
|
|
313
|
+
return {};
|
|
314
|
+
const compatibility = spec.runtimeContract
|
|
315
|
+
? checkRuntimeContractCompatible(spec.runtimeContract, {
|
|
316
|
+
...DAG_CONTROLLER_CAPABILITIES,
|
|
317
|
+
...(ref.packageVersion
|
|
318
|
+
? { controllerVersion: ref.packageVersion }
|
|
319
|
+
: {}),
|
|
320
|
+
})
|
|
321
|
+
: { ok: true };
|
|
322
|
+
return {
|
|
323
|
+
controllerIdentity: {
|
|
324
|
+
status: ref.status,
|
|
325
|
+
capturedAt: ref.capturedAt,
|
|
326
|
+
...(ref.packageName ? { packageName: ref.packageName } : {}),
|
|
327
|
+
...(ref.packageVersion ? { packageVersion: ref.packageVersion } : {}),
|
|
328
|
+
...(ref.packageFingerprint
|
|
329
|
+
? { packageFingerprint: ref.packageFingerprint }
|
|
330
|
+
: {}),
|
|
331
|
+
...(ref.binarySha256 ? { binarySha256: ref.binarySha256 } : {}),
|
|
332
|
+
runtimeContractCompatibility: spec.runtimeContract
|
|
333
|
+
? compatibility.ok
|
|
334
|
+
? "compatible"
|
|
335
|
+
: "incompatible"
|
|
336
|
+
: "legacy-unspecified",
|
|
337
|
+
...(!compatibility.ok
|
|
338
|
+
? { runtimeContractReason: compatibility.reason }
|
|
339
|
+
: {}),
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
}
|
|
294
343
|
function collectTransitiveDescendantNodeIds(spec, rootNodeId) {
|
|
295
344
|
const childrenByParent = new Map();
|
|
296
345
|
for (const task of spec.tasks) {
|
|
@@ -452,6 +501,7 @@ export async function buildDagRunReportEntry(input) {
|
|
|
452
501
|
recoveryRecommendation,
|
|
453
502
|
pausedByNodeId: input.state.pausedByNodeId,
|
|
454
503
|
pauseReason: input.state.pauseReason,
|
|
504
|
+
...summarizeControllerIdentityForReport(input.state.controllerIdentityRef, input.spec),
|
|
455
505
|
nodes,
|
|
456
506
|
};
|
|
457
507
|
const primaryNode = resolvePrimaryFailureNode(partialEntry);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Failure categories that are safe to auto-retry for read-only Pi nodes.
|
|
4
|
+
*
|
|
5
|
+
* These are environmental/transient failures with no repository write
|
|
6
|
+
* side-effects: model connection interruption, provider rate-limiting,
|
|
7
|
+
* temporary provider unavailability, or request timeout.
|
|
8
|
+
*
|
|
9
|
+
* `quota` is intentionally NOT included: a quota exhaustion is an account
|
|
10
|
+
* billing/plan state, not a transient rate limit, and retrying immediately
|
|
11
|
+
* will not help and may waste budget.
|
|
12
|
+
*/
|
|
13
|
+
export const DEFAULT_DAG_RETRY_CATEGORIES = [
|
|
14
|
+
"timeout",
|
|
15
|
+
"network",
|
|
16
|
+
"rate-limit",
|
|
17
|
+
"unavailable",
|
|
18
|
+
];
|
|
19
|
+
const RETRY_SAFE_PI_ROLES = new Set([
|
|
20
|
+
"planner",
|
|
21
|
+
"scout",
|
|
22
|
+
"reviewer",
|
|
23
|
+
"verifier",
|
|
24
|
+
"closeout",
|
|
25
|
+
]);
|
|
26
|
+
export const dagRetryCategorySchema = z.enum(DEFAULT_DAG_RETRY_CATEGORIES);
|
|
27
|
+
export const dagRetryBackoffSchema = z.enum(["exponential"]);
|
|
28
|
+
/**
|
|
29
|
+
* Opt-in retry policy for a DagTask. Generated only for safe read-only Pi
|
|
30
|
+
* nodes. Validation (validate.ts) rejects retryPolicy on writers, dynamic,
|
|
31
|
+
* shell, static, and decision-gate nodes.
|
|
32
|
+
*/
|
|
33
|
+
export const dagRetryPolicySchema = z
|
|
34
|
+
.object({
|
|
35
|
+
/**
|
|
36
|
+
* Total number of attempts (including the first try). Must be 1..5.
|
|
37
|
+
* A value of 1 disables retry (one attempt, no retry).
|
|
38
|
+
*/
|
|
39
|
+
maxAttempts: z.number().int().positive().max(5),
|
|
40
|
+
backoff: dagRetryBackoffSchema.default("exponential"),
|
|
41
|
+
/** Initial delay before the second attempt, in milliseconds. */
|
|
42
|
+
initialDelayMs: z.number().int().nonnegative().default(2000),
|
|
43
|
+
/** Upper bound on a single backoff delay, in milliseconds. */
|
|
44
|
+
maxDelayMs: z.number().int().nonnegative().default(30000),
|
|
45
|
+
/**
|
|
46
|
+
* Explicit set of failure categories eligible for retry. Defaults to
|
|
47
|
+
* {@link DEFAULT_DAG_RETRY_CATEGORIES}. `quota` and other categories
|
|
48
|
+
* are never retried.
|
|
49
|
+
*/
|
|
50
|
+
retryCategories: z
|
|
51
|
+
.array(dagRetryCategorySchema)
|
|
52
|
+
.default([...DEFAULT_DAG_RETRY_CATEGORIES]),
|
|
53
|
+
})
|
|
54
|
+
.superRefine((policy, ctx) => {
|
|
55
|
+
if (policy.maxDelayMs < policy.initialDelayMs) {
|
|
56
|
+
ctx.addIssue({
|
|
57
|
+
code: z.ZodIssueCode.custom,
|
|
58
|
+
message: "retryPolicy.maxDelayMs must be >= retryPolicy.initialDelayMs",
|
|
59
|
+
path: ["maxDelayMs"],
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
/**
|
|
64
|
+
* The default retry policy applied to safe generated read-only Pi nodes.
|
|
65
|
+
* Total attempts: 3, exponential backoff with cap.
|
|
66
|
+
*/
|
|
67
|
+
export const DEFAULT_READ_ONLY_PI_RETRY_POLICY = {
|
|
68
|
+
maxAttempts: 3,
|
|
69
|
+
backoff: "exponential",
|
|
70
|
+
initialDelayMs: 2000,
|
|
71
|
+
maxDelayMs: 30000,
|
|
72
|
+
retryCategories: [...DEFAULT_DAG_RETRY_CATEGORIES],
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Deterministic helper: is this raw failure category eligible for retry under
|
|
76
|
+
* the given policy? Pure function; executor never decides retry eligibility.
|
|
77
|
+
*/
|
|
78
|
+
export function isRetryablePiFailureCategory(rawFailureCategory, options = {}) {
|
|
79
|
+
if (!rawFailureCategory)
|
|
80
|
+
return false;
|
|
81
|
+
const categories = options.retryCategories ?? DEFAULT_DAG_RETRY_CATEGORIES;
|
|
82
|
+
return categories.includes(rawFailureCategory);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Deterministic helper: is this task a safe candidate for read-only Pi retry?
|
|
86
|
+
*
|
|
87
|
+
* A safe candidate is:
|
|
88
|
+
* - executor === "pi"
|
|
89
|
+
* - role is planner/scout/reviewer/verifier/closeout (never supervisor/implementer)
|
|
90
|
+
* - writePolicy is read-only/none/default and toolProfile is not write
|
|
91
|
+
* - NOT dynamic (no dynamicExpansion/Reduction/Condition/LoopUntil)
|
|
92
|
+
* - NOT a decision gate
|
|
93
|
+
*
|
|
94
|
+
* Writers and source supervisors can have non-idempotent side effects or
|
|
95
|
+
* control-flow meaning and must not auto-retry. Dynamic nodes expand into
|
|
96
|
+
* children and must not retry at the controller level.
|
|
97
|
+
*/
|
|
98
|
+
export function isSafeReadOnlyPiRetryCandidate(task) {
|
|
99
|
+
if (task.executor !== "pi")
|
|
100
|
+
return false;
|
|
101
|
+
if (!task.role || !RETRY_SAFE_PI_ROLES.has(task.role))
|
|
102
|
+
return false;
|
|
103
|
+
if (task.toolProfile === "write")
|
|
104
|
+
return false;
|
|
105
|
+
if (task.writePolicy !== undefined &&
|
|
106
|
+
task.writePolicy !== "read-only" &&
|
|
107
|
+
task.writePolicy !== "none") {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
if (task.decisionGate?.enabled)
|
|
111
|
+
return false;
|
|
112
|
+
if (task.dynamicExpansion ||
|
|
113
|
+
task.dynamicReduction ||
|
|
114
|
+
task.dynamicCondition ||
|
|
115
|
+
task.dynamicLoopUntil) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Compute the backoff delay (in ms) to wait before the given attempt number.
|
|
122
|
+
*
|
|
123
|
+
* attemptNumber is 1-based: the delay returned is the wait BEFORE that attempt
|
|
124
|
+
* runs (i.e. the wait between attempt N-1 and attempt N). attemptNumber <= 1
|
|
125
|
+
* returns 0 (no wait before the first try).
|
|
126
|
+
*
|
|
127
|
+
* Exponential: initialDelayMs * 2^(attemptNumber - 2), capped at maxDelayMs.
|
|
128
|
+
*/
|
|
129
|
+
export function computeBackoffDelayMs(attemptNumber, policy) {
|
|
130
|
+
if (attemptNumber <= 1)
|
|
131
|
+
return 0;
|
|
132
|
+
if (policy.backoff !== "exponential") {
|
|
133
|
+
return Math.min(policy.initialDelayMs, policy.maxDelayMs);
|
|
134
|
+
}
|
|
135
|
+
const exponent = attemptNumber - 2;
|
|
136
|
+
const raw = policy.initialDelayMs * Math.pow(2, exponent);
|
|
137
|
+
return Math.min(raw, policy.maxDelayMs);
|
|
138
|
+
}
|
|
@@ -7,6 +7,9 @@ import { moveToCompletedRunDir, moveToPausedRunDir, prepareActiveRunDir, writeRu
|
|
|
7
7
|
import { createDagNodeExecutor } from "./executor-registry.js";
|
|
8
8
|
import { executeDagPiNode } from "../../executors/dag-pi-executor.js";
|
|
9
9
|
import { assertValidDagSpec } from "./validate.js";
|
|
10
|
+
import { assertRuntimeContractCompatible, DAG_CONTROLLER_CAPABILITIES, } from "./runtime-contract.js";
|
|
11
|
+
import { captureControllerIdentity, verifyControllerIdentityForResume, } from "./controller-identity.js";
|
|
12
|
+
import { resolveRunningControllerIdentity } from "../../shared/package-metadata.js";
|
|
10
13
|
import { normalizeDagPromptSources } from "./prompt-source.js";
|
|
11
14
|
import { relocateConvergenceArtifactPaths, relocateRunArtifactPaths, } from "./upstream-artifacts.js";
|
|
12
15
|
import { createSkillSnapshot, prepareSkillSnapshotForContinuation, writeSkillSnapshot, } from "./skill-snapshot.js";
|
|
@@ -145,6 +148,15 @@ export function createInitialRunState(spec, opts, ranks, runId = opts.runId ?? "
|
|
|
145
148
|
}
|
|
146
149
|
export async function runDag(spec, opts) {
|
|
147
150
|
assertValidDagSpec(spec);
|
|
151
|
+
// Capability preflight must fail closed before any run directory or node work.
|
|
152
|
+
const runningIdentity = resolveRunningControllerIdentity();
|
|
153
|
+
if (!runningIdentity) {
|
|
154
|
+
throw new Error("running controller identity could not be resolved; refuse to create an unpinned DAG run");
|
|
155
|
+
}
|
|
156
|
+
assertRuntimeContractCompatible(spec, {
|
|
157
|
+
...DAG_CONTROLLER_CAPABILITIES,
|
|
158
|
+
controllerVersion: runningIdentity.packageVersion,
|
|
159
|
+
});
|
|
148
160
|
const { ranks } = topoSortToRanks(spec);
|
|
149
161
|
const maxConcurrent = Math.max(1, opts.maxConcurrent ?? 4);
|
|
150
162
|
let runId = opts.runId;
|
|
@@ -163,6 +175,12 @@ export async function runDag(spec, opts) {
|
|
|
163
175
|
const runDir = activeRunDir;
|
|
164
176
|
await prepareActiveRunDir(runDir);
|
|
165
177
|
await writeRunSpec(runDir, spec);
|
|
178
|
+
// Freeze the running controller identity as a run-owned fact before the
|
|
179
|
+
// first canonical state write, reusing the identity resolved for preflight.
|
|
180
|
+
state.controllerIdentityRef = await captureControllerIdentity({
|
|
181
|
+
runDir,
|
|
182
|
+
identity: runningIdentity,
|
|
183
|
+
});
|
|
166
184
|
// Resolve and snapshot all skill profiles before any node executes.
|
|
167
185
|
try {
|
|
168
186
|
const snapshot = await createSkillSnapshot({
|
|
@@ -224,6 +242,20 @@ export async function resumeDagRun(opts) {
|
|
|
224
242
|
if (isTerminalDagRunStatus(state.status)) {
|
|
225
243
|
throw new Error(`dag run ${opts.runId} is already terminal (status=${state.status})`);
|
|
226
244
|
}
|
|
245
|
+
// Runtime contract + controller identity must be re-verified before executing
|
|
246
|
+
// any remaining node on resume; drift fails closed.
|
|
247
|
+
const runningIdentity = resolveRunningControllerIdentity();
|
|
248
|
+
if (!runningIdentity) {
|
|
249
|
+
throw new Error("running controller identity could not be resolved; refuse to resume an unpinned DAG run");
|
|
250
|
+
}
|
|
251
|
+
assertRuntimeContractCompatible(spec, {
|
|
252
|
+
...DAG_CONTROLLER_CAPABILITIES,
|
|
253
|
+
controllerVersion: runningIdentity.packageVersion,
|
|
254
|
+
});
|
|
255
|
+
await verifyControllerIdentityForResume({
|
|
256
|
+
runDir,
|
|
257
|
+
state,
|
|
258
|
+
});
|
|
227
259
|
const decisionNodeId = state.humanDecisionNodeId;
|
|
228
260
|
if (!decisionNodeId) {
|
|
229
261
|
throw new Error(`dag run ${opts.runId} has no human decision checkpoint; was it approved?`);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { DAG_AGENT_RUNTIME_PI_ONLY, DAG_REPAIR_WRITER_PROTOCOL_EXPLICIT_NODE_V1, DAG_RUNTIME_CONTRACT_SCHEMA_VERSION, } from "./types.js";
|
|
2
|
+
export const DAG_CONTROLLER_CAPABILITIES = {
|
|
3
|
+
schemaVersion: DAG_RUNTIME_CONTRACT_SCHEMA_VERSION,
|
|
4
|
+
agentRuntime: DAG_AGENT_RUNTIME_PI_ONLY,
|
|
5
|
+
repairWriterProtocol: DAG_REPAIR_WRITER_PROTOCOL_EXPLICIT_NODE_V1,
|
|
6
|
+
};
|
|
7
|
+
function parseSemver(value) {
|
|
8
|
+
const match = value.match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
9
|
+
if (!match)
|
|
10
|
+
return undefined;
|
|
11
|
+
return {
|
|
12
|
+
major: Number(match[1]),
|
|
13
|
+
minor: Number(match[2]),
|
|
14
|
+
patch: Number(match[3]),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** Returns true when `actual` is greater than or equal to `required`. */
|
|
18
|
+
export function semverGte(actual, required) {
|
|
19
|
+
const a = parseSemver(actual);
|
|
20
|
+
const r = parseSemver(required);
|
|
21
|
+
if (!a || !r)
|
|
22
|
+
return false;
|
|
23
|
+
if (a.major !== r.major)
|
|
24
|
+
return a.major > r.major;
|
|
25
|
+
if (a.minor !== r.minor)
|
|
26
|
+
return a.minor > r.minor;
|
|
27
|
+
return a.patch >= r.patch;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Decide whether a controller can run a DagSpec's declared runtimeContract.
|
|
31
|
+
*
|
|
32
|
+
* Capability fields are authoritative: an unsupported agentRuntime or
|
|
33
|
+
* repairWriterProtocol fails closed regardless of version. minimumControllerVersion
|
|
34
|
+
* is only consulted for diagnosis when the controller version is known and the
|
|
35
|
+
* capabilities otherwise match.
|
|
36
|
+
*
|
|
37
|
+
* Legacy DagSpecs without a runtimeContract are always compatible (they predate
|
|
38
|
+
* the capability handshake and rely on runtime-side derivation).
|
|
39
|
+
*/
|
|
40
|
+
export function checkRuntimeContractCompatible(contract, capabilities = DAG_CONTROLLER_CAPABILITIES) {
|
|
41
|
+
if (!contract)
|
|
42
|
+
return { ok: true };
|
|
43
|
+
if (contract.schemaVersion !== capabilities.schemaVersion) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
reason: `runtime contract schemaVersion ${contract.schemaVersion} is not supported by this controller (supports ${capabilities.schemaVersion}); regenerate the DAG or upgrade the controller`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (contract.agentRuntime !== capabilities.agentRuntime) {
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
reason: `runtime contract requires agentRuntime "${contract.agentRuntime}" but this controller advertises "${capabilities.agentRuntime}"; upgrade the controller or regenerate the DAG`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (contract.repairWriterProtocol !== capabilities.repairWriterProtocol) {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
reason: `runtime contract requires repairWriterProtocol "${contract.repairWriterProtocol}" but this controller advertises "${capabilities.repairWriterProtocol}"; upgrade the controller or regenerate the DAG`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (contract.minimumControllerVersion &&
|
|
62
|
+
!capabilities.controllerVersion) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
reason: `runtime contract requires controller version >= ${contract.minimumControllerVersion} but the running controller version could not be resolved; refuse to continue unpinned`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (contract.minimumControllerVersion &&
|
|
69
|
+
capabilities.controllerVersion &&
|
|
70
|
+
!semverGte(capabilities.controllerVersion, contract.minimumControllerVersion)) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
reason: `runtime contract requires controller version >= ${contract.minimumControllerVersion} but this controller reports ${capabilities.controllerVersion}; upgrade the controller`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return { ok: true };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Fail closed before any node execution when the DagSpec's runtimeContract is
|
|
80
|
+
* incompatible with this controller.
|
|
81
|
+
*/
|
|
82
|
+
export function assertRuntimeContractCompatible(spec, capabilities = DAG_CONTROLLER_CAPABILITIES) {
|
|
83
|
+
const result = checkRuntimeContractCompatible(spec.runtimeContract, capabilities);
|
|
84
|
+
if (!result.ok) {
|
|
85
|
+
throw new Error(`incompatible DAG runtime contract: ${result.reason}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -337,6 +337,8 @@ export async function createSkillSnapshot(input) {
|
|
|
337
337
|
includeLearnedPatterns: request.includeLearnedPatterns,
|
|
338
338
|
perSkillMaxChars: request.perSkillMaxChars,
|
|
339
339
|
totalMaxChars: request.totalMaxChars,
|
|
340
|
+
...(input.homeDir !== undefined ? { homeDir: input.homeDir } : {}),
|
|
341
|
+
...(input.env !== undefined ? { env: input.env } : {}),
|
|
340
342
|
});
|
|
341
343
|
profiles.push({ ...request, resolvedInstructions });
|
|
342
344
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { assertDagPromptSourceRule } from "./prompt-source.js";
|
|
3
|
+
import { dagRetryPolicySchema } from "./retry-policy.js";
|
|
3
4
|
export const dagComplexitySchema = z.enum(["HIGH", "MED", "LOW"]);
|
|
4
5
|
export const dagNodeExecutorSchema = z.enum([
|
|
5
6
|
"pi",
|
|
@@ -33,6 +34,32 @@ export const dagRepairArtifactGateSchema = z.object({
|
|
|
33
34
|
fromNodeId: z
|
|
34
35
|
.string()
|
|
35
36
|
.regex(/^[a-z][a-z0-9-]*$/, "fromNodeId must be kebab-case"),
|
|
37
|
+
/**
|
|
38
|
+
* Explicit reference to the governed Pi repair writer this gate feeds.
|
|
39
|
+
* New generated supervised DAGs must set this. Legacy DAGs without it are
|
|
40
|
+
* only accepted when a unique safe downstream Pi writer can be derived.
|
|
41
|
+
*/
|
|
42
|
+
repairNodeId: z
|
|
43
|
+
.string()
|
|
44
|
+
.regex(/^[a-z][a-z0-9-]*$/, "repairNodeId must be kebab-case")
|
|
45
|
+
.optional(),
|
|
46
|
+
});
|
|
47
|
+
export const DAG_RUNTIME_CONTRACT_SCHEMA_VERSION = 1;
|
|
48
|
+
export const DAG_AGENT_RUNTIME_PI_ONLY = "pi-only";
|
|
49
|
+
export const DAG_REPAIR_WRITER_PROTOCOL_EXPLICIT_NODE_V1 = "explicit-node-v1";
|
|
50
|
+
export const dagRuntimeContractSchema = z.object({
|
|
51
|
+
schemaVersion: z.literal(DAG_RUNTIME_CONTRACT_SCHEMA_VERSION),
|
|
52
|
+
agentRuntime: z.literal(DAG_AGENT_RUNTIME_PI_ONLY),
|
|
53
|
+
repairWriterProtocol: z.literal(DAG_REPAIR_WRITER_PROTOCOL_EXPLICIT_NODE_V1),
|
|
54
|
+
/**
|
|
55
|
+
* Optional minimum controller version for diagnosis / fail-below. Capability
|
|
56
|
+
* fields above are primary; semver is only used for guidance when the
|
|
57
|
+
* controller is otherwise compatible.
|
|
58
|
+
*/
|
|
59
|
+
minimumControllerVersion: z
|
|
60
|
+
.string()
|
|
61
|
+
.regex(/^\d+\.\d+\.\d+([-+].+)?$/, "minimumControllerVersion must be semver")
|
|
62
|
+
.optional(),
|
|
36
63
|
});
|
|
37
64
|
export const dagVerdictGateSchema = z.object({
|
|
38
65
|
fromNodeId: z
|
|
@@ -44,7 +71,7 @@ export const dagVerdictGateSchema = z.object({
|
|
|
44
71
|
});
|
|
45
72
|
export const ENV_VAR_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
|
|
46
73
|
export const dagVersionSchema = z
|
|
47
|
-
.union([z.literal(1), z.literal(2)])
|
|
74
|
+
.union([z.literal(1), z.literal(2), z.literal(3)])
|
|
48
75
|
.default(1);
|
|
49
76
|
export const dagRoleSchema = z.enum([
|
|
50
77
|
"planner",
|
|
@@ -217,6 +244,7 @@ export const dagTaskSchema = z.object({
|
|
|
217
244
|
allowedPaths: z.array(z.string()).optional().default([]),
|
|
218
245
|
forbiddenPaths: z.array(z.string()).optional().default([]),
|
|
219
246
|
decisionGate: dagDecisionGateSchema.optional(),
|
|
247
|
+
retryPolicy: dagRetryPolicySchema.optional(),
|
|
220
248
|
dynamicExpansion: dagDynamicExpansionSchema.optional(),
|
|
221
249
|
dynamicReduction: dagDynamicReductionSchema.optional(),
|
|
222
250
|
dynamicCondition: dagDynamicConditionSchema.optional(),
|
|
@@ -240,6 +268,7 @@ export const dagSpecSchema = z
|
|
|
240
268
|
.object({
|
|
241
269
|
version: dagVersionSchema,
|
|
242
270
|
title: z.string().min(1),
|
|
271
|
+
runtimeContract: dagRuntimeContractSchema.optional(),
|
|
243
272
|
outputLanguage: dagOutputLanguageSchema.optional(),
|
|
244
273
|
objective: z.string().optional(),
|
|
245
274
|
successCriteria: z.array(z.string()).optional(),
|
|
@@ -252,6 +281,20 @@ export const dagSpecSchema = z
|
|
|
252
281
|
tasks: z.array(dagTaskSchema).min(1),
|
|
253
282
|
})
|
|
254
283
|
.superRefine((spec, ctx) => {
|
|
284
|
+
if (spec.runtimeContract && spec.version !== 3) {
|
|
285
|
+
ctx.addIssue({
|
|
286
|
+
code: z.ZodIssueCode.custom,
|
|
287
|
+
message: "runtimeContract requires DagSpec version 3",
|
|
288
|
+
path: ["version"],
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
if (spec.version === 3 && !spec.runtimeContract) {
|
|
292
|
+
ctx.addIssue({
|
|
293
|
+
code: z.ZodIssueCode.custom,
|
|
294
|
+
message: "DagSpec version 3 requires runtimeContract",
|
|
295
|
+
path: ["runtimeContract"],
|
|
296
|
+
});
|
|
297
|
+
}
|
|
255
298
|
// Catch raw cursor keys that Zod .strict() on nested objects already rejects when parsed
|
|
256
299
|
// via parseDagSpec; this refine covers typed object construction paths.
|
|
257
300
|
const models = spec.executorModels;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { DEFAULT_DAG_EXECUTOR_MODELS, ENV_VAR_NAME_PATTERN, } from "./types.js";
|
|
2
2
|
import { resolveShellCommands } from "../../executors/shell-executor.js";
|
|
3
3
|
import { pathMatchesPattern } from "../../shared/git-progress.js";
|
|
4
|
+
import { resolveRepairTaskForGate } from "./repair-artifact.js";
|
|
4
5
|
import { topoSortToRanks } from "./topo.js";
|
|
6
|
+
import { isSafeReadOnlyPiRetryCandidate } from "./retry-policy.js";
|
|
5
7
|
const GOVERNANCE_WARNING_TYPES = new Set([
|
|
6
8
|
"read-only-missing-artifacts-forbidden",
|
|
7
9
|
"read-only-prompt-mentions-artifact-writes",
|
|
@@ -176,14 +178,65 @@ function validateRepairArtifactGateConfig(task, spec, issues) {
|
|
|
176
178
|
});
|
|
177
179
|
}
|
|
178
180
|
const upstream = spec.tasks.find((candidate) => candidate.id === repairArtifactGate.fromNodeId);
|
|
179
|
-
if (
|
|
180
|
-
return;
|
|
181
|
-
if (upstream.executor === "shell") {
|
|
181
|
+
if (upstream && upstream.executor === "shell") {
|
|
182
182
|
issues.push({
|
|
183
183
|
type: "invalid-repair-artifact-gate-config",
|
|
184
184
|
message: `task ${task.id} shell.repairArtifactGate.fromNodeId "${repairArtifactGate.fromNodeId}" must reference a non-shell upstream artifact node`,
|
|
185
185
|
});
|
|
186
186
|
}
|
|
187
|
+
const resolution = resolveRepairTaskForGate({
|
|
188
|
+
tasks: spec.tasks,
|
|
189
|
+
gateTask: task,
|
|
190
|
+
});
|
|
191
|
+
if (!resolution.ok) {
|
|
192
|
+
issues.push({
|
|
193
|
+
type: "invalid-repair-artifact-gate-config",
|
|
194
|
+
message: `task ${task.id} ${resolution.reason}`,
|
|
195
|
+
});
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const hardVerifyCandidates = spec.tasks.filter((candidate) => candidate.depends_on.includes(resolution.repairTask.id) &&
|
|
199
|
+
candidate.executor === "shell" &&
|
|
200
|
+
candidate.role === "verifier" &&
|
|
201
|
+
candidate.shell?.verifyEvidence?.phase === "final" &&
|
|
202
|
+
candidate.shell.verifyEvidence.quota === "full" &&
|
|
203
|
+
candidate.shell.verifyEvidence.finalFullRequired === true);
|
|
204
|
+
if (hardVerifyCandidates.length !== 1) {
|
|
205
|
+
issues.push({
|
|
206
|
+
type: "invalid-repair-artifact-gate-config",
|
|
207
|
+
message: hardVerifyCandidates.length === 0
|
|
208
|
+
? `task ${task.id} repair node "${resolution.repairTask.id}" must have one direct downstream final/full hard verification shell node with finalFullRequired=true`
|
|
209
|
+
: `task ${task.id} repair node "${resolution.repairTask.id}" has multiple hard verification shell nodes (${hardVerifyCandidates.map((candidate) => candidate.id).join(", ")})`,
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const hardVerify = hardVerifyCandidates[0];
|
|
214
|
+
const descendantIds = new Set();
|
|
215
|
+
const queue = [hardVerify.id];
|
|
216
|
+
while (queue.length > 0) {
|
|
217
|
+
const parentId = queue.shift();
|
|
218
|
+
for (const candidate of spec.tasks) {
|
|
219
|
+
if (candidate.depends_on.includes(parentId) &&
|
|
220
|
+
!descendantIds.has(candidate.id)) {
|
|
221
|
+
descendantIds.add(candidate.id);
|
|
222
|
+
queue.push(candidate.id);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const reviewCandidates = spec.tasks.filter((candidate) => descendantIds.has(candidate.id) &&
|
|
227
|
+
candidate.executor === "pi" &&
|
|
228
|
+
candidate.role === "reviewer" &&
|
|
229
|
+
!candidate.decisionGate?.enabled &&
|
|
230
|
+
candidate.writePolicy === "read-only" &&
|
|
231
|
+
(candidate.outputContract?.includes("VERDICT:") ?? false));
|
|
232
|
+
if (reviewCandidates.length !== 1) {
|
|
233
|
+
issues.push({
|
|
234
|
+
type: "invalid-repair-artifact-gate-config",
|
|
235
|
+
message: reviewCandidates.length === 0
|
|
236
|
+
? `task ${task.id} hard verification node "${hardVerify.id}" must reach one read-only Pi reviewer with a VERDICT output contract`
|
|
237
|
+
: `task ${task.id} hard verification node "${hardVerify.id}" has multiple Pi reviewer nodes (${reviewCandidates.map((candidate) => candidate.id).join(", ")})`,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
187
240
|
}
|
|
188
241
|
function validateShellVerdictGateGovernance(task, commands, issues) {
|
|
189
242
|
if (task.executor !== "shell") {
|
|
@@ -229,7 +282,7 @@ function writeSetEntriesOverlap(a, b) {
|
|
|
229
282
|
pathMatchesPattern(probeB, a)));
|
|
230
283
|
}
|
|
231
284
|
function shouldValidateWritePolicy(spec, task) {
|
|
232
|
-
return (spec.version
|
|
285
|
+
return (spec.version >= 2 ||
|
|
233
286
|
task.writePolicy !== undefined ||
|
|
234
287
|
task.writeSet !== undefined);
|
|
235
288
|
}
|
|
@@ -414,6 +467,16 @@ function validateStaticTaskConfig(task, issues) {
|
|
|
414
467
|
});
|
|
415
468
|
}
|
|
416
469
|
}
|
|
470
|
+
function validateRetryPolicyTaskConfig(task, issues) {
|
|
471
|
+
if (task.retryPolicy === undefined)
|
|
472
|
+
return;
|
|
473
|
+
if (!isSafeReadOnlyPiRetryCandidate(task)) {
|
|
474
|
+
issues.push({
|
|
475
|
+
type: "invalid-retry-policy",
|
|
476
|
+
message: `task ${task.id} declares retryPolicy but is not a safe read-only non-dynamic Pi node; retry is only allowed for read-only/none Pi planner/scout/reviewer/verifier/closeout nodes without write, supervisor, or dynamic capabilities`,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
417
480
|
function validateDecisionGateTaskConfig(task, issues) {
|
|
418
481
|
if (!task.decisionGate?.enabled) {
|
|
419
482
|
return;
|
|
@@ -512,6 +575,7 @@ export function validateDagSpec(spec) {
|
|
|
512
575
|
validateShellTaskConfig(task, spec, issues);
|
|
513
576
|
validateStaticTaskConfig(task, issues);
|
|
514
577
|
validateDecisionGateTaskConfig(task, issues);
|
|
578
|
+
validateRetryPolicyTaskConfig(task, issues);
|
|
515
579
|
}
|
|
516
580
|
validateSameRankWriteSetConflicts(spec, ranks, issues);
|
|
517
581
|
validateSameRankAgentAttributionRisks(spec, ranks, issues);
|