@tea-agent/loop-agent 0.11.0 → 0.13.0-alpha.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 +74 -1
- package/README.md +33 -4
- package/dist/application/dag/generate-task-dag.js +45 -0
- package/dist/application/dag/run-dag.js +10 -0
- package/dist/application/dag/validate-dag.js +11 -0
- package/dist/cli/command-definitions.js +10 -3
- package/dist/commands/init.js +74 -7
- package/dist/commands/knowledge.js +129 -31
- package/dist/governance/manifest-types.js +3 -0
- package/dist/shared/package-metadata.js +135 -0
- package/dist/task/config-types.js +6 -1
- package/dist/worker/cli.js +99 -2
- package/dist/worker/delivery/package.js +3 -3
- package/dist/worker/feature/decision-loader.js +37 -6
- package/dist/worker/feature/next-action.js +10 -2
- package/dist/worker/feature/ready-plan-projection.js +81 -0
- package/dist/worker/feature/reducer.js +2 -1
- package/dist/worker/feature/review.js +19 -2
- package/dist/worker/feature/run.js +27 -2
- package/dist/worker/follow-up/approve.js +5 -2
- package/dist/worker/follow-up/factory.js +1 -1
- package/dist/worker/observability/event-history.js +216 -0
- package/dist/worker/observability/read-model.js +552 -118
- package/dist/worker/observe/paths.js +17 -0
- package/dist/worker/observe/routes.js +310 -23
- package/dist/worker/observe/server.js +59 -1
- package/dist/worker/observe/spec-evidence.js +281 -0
- package/dist/worker/observe/static/api.js +46 -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 +133 -0
- package/dist/worker/observe/static/router.js +93 -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 +1731 -495
- package/dist/worker/observe/static/views/batch.js +227 -0
- package/dist/worker/observe/static/views/dag-graph.js +172 -0
- package/dist/worker/observe/static/views/dag-inspector.js +596 -0
- package/dist/worker/observe/static/views/dag.js +362 -0
- package/dist/worker/observe/static/views/dashboard.js +445 -0
- package/dist/worker/observe/static/views/failures.js +143 -0
- package/dist/worker/observe/static/views/feature.js +492 -0
- package/dist/worker/observe/static/views/pool.js +350 -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 +314 -0
- package/dist/worker/observe/static/views/timeline.js +163 -0
- package/dist/worker/pool/doctor.js +165 -0
- package/dist/worker/pool/migrate-state.js +303 -0
- package/dist/worker/pool/run-store.js +205 -17
- package/dist/worker/pool/types.js +17 -1
- package/dist/worker/pool/validation.js +100 -15
- package/dist/worker/report/morning-report.js +12 -2
- package/dist/worker/runner/run-ready.js +41 -26
- package/dist/worker/task-graph/ready-planner.js +136 -0
- package/dist/workflows/dag/controller-identity.js +104 -0
- package/dist/workflows/dag/convergence/controller.js +16 -8
- package/dist/workflows/dag/failure-routing.js +12 -1
- package/dist/workflows/dag/init-hybrid.js +1233 -11
- 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 +45 -1
- package/dist/workflows/dag/validate.js +68 -4
- package/docs/README.md +1 -1
- package/docs/agent-dag-recovery-playbook.md +9 -0
- package/docs/agent-dag-runner.md +26 -1
- package/docs/architecture/dag-execution.md +6 -0
- package/docs/architecture/evolution.md +7 -5
- package/docs/architecture/facts-and-state.md +15 -2
- package/docs/architecture/worker-and-feature.md +6 -2
- package/docs/decisions/README.md +3 -0
- package/docs/design/README.md +12 -3
- package/docs/exec-plans/active/README.md +2 -2
- package/docs/exec-plans/completed/README.md +12 -0
- package/docs/feature-workflow.md +108 -2
- package/docs/loop-agent-harness.md +15 -4
- package/docs/progress/README.md +22 -0
- package/docs/reports/README.md +14 -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 +288 -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/docs/templates/knowledge-graph-bootstrap-dag.json +118 -0
- package/docs/templates/knowledge-sync-dag.json +177 -0
- package/docs/templates/knowledge-sync-draft.schema.json +71 -0
- package/docs/verification-matrix.md +2 -1
- package/package.json +8 -2
- package/scripts/kb-bootstrap-init-skeleton.sh +239 -0
- package/scripts/kb-graph-incremental-prepare.mjs +372 -0
- package/scripts/kb-graph-incremental-prepare.sh +5 -0
- package/scripts/kb-graph-materialize.mjs +105 -0
- package/scripts/kb-graph-materialize.sh +4 -0
- package/scripts/kb-graph-promote.mjs +153 -0
- package/scripts/kb-graph-promote.sh +4 -0
- package/scripts/kb-query.mjs +554 -0
- package/scripts/kb-query.sh +5 -0
- package/skills/agent-worker/SKILL.md +3 -1
- package/skills/agent-worker/references/agent-worker-operator.md +18 -1
- package/skills/frontend-design-review/SKILL.md +26 -24
- package/skills/frontend-implementation/SKILL.md +29 -26
- package/skills/frontend-implementation/references/node-contracts.md +50 -19
- package/skills/frontend-review/SKILL.md +1 -1
- package/skills/loop-agent/references/command-reference.md +2 -0
- package/skills/loop-agent/references/hybrid-dag.md +22 -3
- package/skills/loop-agent/references/verification-and-failure-handling.md +6 -0
|
@@ -1,20 +1,42 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { readFile, readdir } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { getRunsJsonlPath,
|
|
4
|
+
import { getRunsJsonlPath, getStatesRoot, } from "./run-store.js";
|
|
5
|
+
import { TASK_POOL_STATE_ERROR_CODES, } from "./types.js";
|
|
5
6
|
export const taskPoolRunFactSchema = z.object({
|
|
6
|
-
schemaVersion: z.literal(1),
|
|
7
|
-
|
|
7
|
+
schemaVersion: z.literal(1),
|
|
8
|
+
batchRunId: z.string().min(1),
|
|
9
|
+
workerRunId: z.string().min(1),
|
|
10
|
+
taskId: z.string().min(1),
|
|
11
|
+
featureId: z.string().min(1),
|
|
12
|
+
status: z.enum(["succeeded", "failed", "run-error"]),
|
|
13
|
+
recordedAt: z.string().datetime(),
|
|
8
14
|
}).passthrough();
|
|
9
15
|
export const taskPoolStateFactSchema = z.object({
|
|
10
|
-
taskId: z.string().min(1),
|
|
16
|
+
taskId: z.string().min(1),
|
|
17
|
+
status: z.enum([
|
|
18
|
+
"Draft",
|
|
19
|
+
"Ready",
|
|
20
|
+
"Queued",
|
|
21
|
+
"Running",
|
|
22
|
+
"AgentCompleted",
|
|
23
|
+
"VerificationRunning",
|
|
24
|
+
"HumanReview",
|
|
25
|
+
"Done",
|
|
26
|
+
"Failed",
|
|
27
|
+
"Blocked",
|
|
28
|
+
"Abandoned",
|
|
29
|
+
]),
|
|
30
|
+
updatedAt: z.string().datetime(),
|
|
31
|
+
schemaVersion: z.union([z.literal(1), z.literal(2)]).optional(),
|
|
32
|
+
featureId: z.string().min(1).optional(),
|
|
11
33
|
}).passthrough();
|
|
12
34
|
export async function readValidTaskPoolRuns(repoRoot) {
|
|
13
35
|
const records = [];
|
|
14
36
|
const warnings = [];
|
|
15
37
|
try {
|
|
16
38
|
const raw = await readFile(getRunsJsonlPath(repoRoot), "utf-8");
|
|
17
|
-
for (const [index, line] of raw.split(/\r?\n/).filter(Boolean).entries())
|
|
39
|
+
for (const [index, line] of raw.split(/\r?\n/).filter(Boolean).entries()) {
|
|
18
40
|
try {
|
|
19
41
|
const parsed = taskPoolRunFactSchema.safeParse(JSON.parse(line));
|
|
20
42
|
if (parsed.success)
|
|
@@ -25,6 +47,7 @@ export async function readValidTaskPoolRuns(repoRoot) {
|
|
|
25
47
|
catch {
|
|
26
48
|
warnings.push(`Task Pool runs.jsonl line ${index + 1} is corrupt`);
|
|
27
49
|
}
|
|
50
|
+
}
|
|
28
51
|
}
|
|
29
52
|
catch (error) {
|
|
30
53
|
if (!isNotFound(error))
|
|
@@ -32,23 +55,80 @@ export async function readValidTaskPoolRuns(repoRoot) {
|
|
|
32
55
|
}
|
|
33
56
|
return { records, warnings };
|
|
34
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Valid state reader for morning-report and similar consumers.
|
|
60
|
+
* - v2 feature-scoped states: included when path/content identity match.
|
|
61
|
+
* - bare taskId collisions among v2: warning, do not overwrite records.
|
|
62
|
+
* - legacy flat states: warning with migration-required code; never treated as Ready facts.
|
|
63
|
+
*/
|
|
35
64
|
export async function readValidTaskPoolStates(repoRoot) {
|
|
36
65
|
const records = {};
|
|
37
66
|
const warnings = [];
|
|
38
|
-
const stateDir =
|
|
67
|
+
const stateDir = getStatesRoot(repoRoot);
|
|
39
68
|
try {
|
|
40
|
-
|
|
41
|
-
|
|
69
|
+
const entries = await readdir(stateDir, { withFileTypes: true });
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
if (entry.isFile() && entry.name.endsWith(".json")) {
|
|
72
|
+
const entryPath = path.join(stateDir, entry.name);
|
|
42
73
|
try {
|
|
43
|
-
const parsed = taskPoolStateFactSchema.safeParse(JSON.parse(await readFile(
|
|
44
|
-
if (parsed.success
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
74
|
+
const parsed = taskPoolStateFactSchema.safeParse(JSON.parse(await readFile(entryPath, "utf-8")));
|
|
75
|
+
if (!parsed.success) {
|
|
76
|
+
warnings.push(`Task Pool state is semantically invalid: ${entry.name}`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const expectedTaskId = entry.name.slice(0, -5);
|
|
80
|
+
if (parsed.data.taskId !== expectedTaskId) {
|
|
81
|
+
warnings.push(`Task Pool state is semantically invalid: ${entry.name}`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
warnings.push(`${TASK_POOL_STATE_ERROR_CODES.MIGRATION_REQUIRED}: legacy state ${entry.name}`);
|
|
48
85
|
}
|
|
49
86
|
catch {
|
|
50
|
-
warnings.push(`Task Pool state is corrupt: ${entry}`);
|
|
87
|
+
warnings.push(`Task Pool state is corrupt: ${entry.name}`);
|
|
51
88
|
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!entry.isDirectory())
|
|
92
|
+
continue;
|
|
93
|
+
const featureId = entry.name;
|
|
94
|
+
const featureDir = path.join(stateDir, featureId);
|
|
95
|
+
let featureEntries;
|
|
96
|
+
try {
|
|
97
|
+
featureEntries = await readdir(featureDir);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
warnings.push(`Task Pool feature state directory is unreadable: ${featureId}`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
for (const fileName of featureEntries) {
|
|
104
|
+
if (!fileName.endsWith(".json"))
|
|
105
|
+
continue;
|
|
106
|
+
const relative = `${featureId}/${fileName}`;
|
|
107
|
+
try {
|
|
108
|
+
const parsed = taskPoolStateFactSchema.safeParse(JSON.parse(await readFile(path.join(featureDir, fileName), "utf-8")));
|
|
109
|
+
if (!parsed.success) {
|
|
110
|
+
warnings.push(`Task Pool state is semantically invalid: ${relative}`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const expectedTaskId = fileName.slice(0, -5);
|
|
114
|
+
const data = parsed.data;
|
|
115
|
+
if (data.taskId !== expectedTaskId ||
|
|
116
|
+
data.featureId !== featureId ||
|
|
117
|
+
data.schemaVersion !== 2) {
|
|
118
|
+
warnings.push(`${TASK_POOL_STATE_ERROR_CODES.PATH_MISMATCH}: ${relative}`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (records[data.taskId]) {
|
|
122
|
+
warnings.push(`${TASK_POOL_STATE_ERROR_CODES.IDENTITY_AMBIGUOUS}: multiple features share taskId ${data.taskId}`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
records[data.taskId] = data;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
warnings.push(`Task Pool state is corrupt: ${relative}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
52
132
|
}
|
|
53
133
|
catch (error) {
|
|
54
134
|
if (!isNotFound(error))
|
|
@@ -56,4 +136,9 @@ export async function readValidTaskPoolStates(repoRoot) {
|
|
|
56
136
|
}
|
|
57
137
|
return { records, warnings };
|
|
58
138
|
}
|
|
59
|
-
function isNotFound(error) {
|
|
139
|
+
function isNotFound(error) {
|
|
140
|
+
return Boolean(error &&
|
|
141
|
+
typeof error === "object" &&
|
|
142
|
+
"code" in error &&
|
|
143
|
+
error.code === "ENOENT");
|
|
144
|
+
}
|
|
@@ -23,7 +23,16 @@ export async function renderMorningReport(options) {
|
|
|
23
23
|
...(features.length === 0 ? ["- Status: no Feature Packet discovered", "- Next Action: add or locate a Feature Packet", "- Why: no shared Feature read model is available", "- Evidence: none"] : features.flatMap((feature) => {
|
|
24
24
|
const why = feature.blockingItems[0]?.message ?? `${feature.summary.tasksSucceeded}/${feature.summary.tasksTotal} tasks; ${feature.summary.requiredAcCovered}/${feature.summary.requiredAcTotal} required AC`;
|
|
25
25
|
const evidence = [feature.evidence.delivery, feature.evidence.closeout, feature.evidence.morningReport, feature.evidence.observeSnapshot, ...feature.blockingItems.flatMap((item) => item.evidence)].find(Boolean) ?? "none";
|
|
26
|
-
|
|
26
|
+
const selected = feature.planning?.selected[0];
|
|
27
|
+
const deferred = feature.planning?.deferred ?? [];
|
|
28
|
+
const blocked = feature.planning?.blocked ?? [];
|
|
29
|
+
const deferredText = deferred.length > 0
|
|
30
|
+
? deferred.map((item) => `${item.taskId} (${item.reasonCode})`).join(", ")
|
|
31
|
+
: "none";
|
|
32
|
+
const blockedText = blocked.length > 0
|
|
33
|
+
? blocked.map((item) => `${item.taskId} (${item.reasonCode}${item.blockedBy.length ? `; blockedBy ${item.blockedBy.join(",")}` : ""})`).join(", ")
|
|
34
|
+
: "none";
|
|
35
|
+
return [`### ${feature.featureId}`, "", `- Status: ${feature.status}`, `- Next Task: ${selected ? `${selected.taskId} (${selected.priority})` : "none"}`, `- Deferred: ${deferredText}`, `- Blocked: ${blockedText}`, `- Next Action: ${feature.nextAction?.command ?? feature.nextAction?.label ?? "none"}`, `- Why: ${why}`, `- Evidence: ${evidence}`, ""];
|
|
27
36
|
})),
|
|
28
37
|
"## Summary",
|
|
29
38
|
"",
|
|
@@ -40,7 +49,8 @@ export async function renderMorningReport(options) {
|
|
|
40
49
|
];
|
|
41
50
|
for (const run of runs) {
|
|
42
51
|
const followUp = await followUpSummary(run, options.repoRoot);
|
|
43
|
-
|
|
52
|
+
const identity = `${run.featureId}/${run.taskId}`;
|
|
53
|
+
lines.push(`| ${identity} | ${run.status} | ${run.workerRunId} | ${run.failure?.category ?? "-"} | ${followUp ?? run.failure?.derivedFollowUpTaskId ?? "-"} | ${await artifactSummary(run)} |`);
|
|
44
54
|
}
|
|
45
55
|
if (followUps > 0) {
|
|
46
56
|
lines.push("", "## Human Actions", "");
|
|
@@ -4,10 +4,10 @@ import path from "node:path";
|
|
|
4
4
|
import YAML from "yaml";
|
|
5
5
|
import { controllerIdentitiesMatch, controllerIdentityExpectationFailure, resolveControllerIdentity, } from "../loop-agent/loop-agent-client.js";
|
|
6
6
|
import { deriveFailureRoute, deriveFailureRouteFromError, } from "../pool/failure-routing.js";
|
|
7
|
-
import { findRunByWorkerRunId, getTaskPoolRoot,
|
|
7
|
+
import { findRunByWorkerRunId, getTaskPoolRoot, readFeatureTaskPoolStates, recordTaskPoolRun, writeTaskPoolState, } from "../pool/run-store.js";
|
|
8
8
|
import { runTaskSpec, } from "../run-task/run-task.js";
|
|
9
9
|
import { formatDuration, noopProgressReporter, } from "../progress-reporter.js";
|
|
10
|
-
import {
|
|
10
|
+
import { planReadyTasks } from "../task-graph/ready-planner.js";
|
|
11
11
|
import { taskGraphSpecSchema } from "../task-graph/task-graph-schema.js";
|
|
12
12
|
import { taskSpecSchema } from "../task-spec/schema.js";
|
|
13
13
|
import { preflightTargetRepo } from "../preflight.js";
|
|
@@ -34,15 +34,26 @@ export async function runReadyTasks(options) {
|
|
|
34
34
|
const startedAt = now.toISOString();
|
|
35
35
|
const batchRunId = options.batchRunId ?? buildBatchRunId(now);
|
|
36
36
|
const graph = await loadTaskGraph(options.featureDir);
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
const
|
|
37
|
+
const featureId = graph.feature_id;
|
|
38
|
+
const states = await readFeatureTaskPoolStates(options.repoRoot, featureId);
|
|
39
|
+
const taskSpecs = await loadFeatureTaskSpecs(options.featureDir, graph);
|
|
40
|
+
const plan = planReadyTasks({
|
|
41
|
+
featureId,
|
|
42
|
+
graph,
|
|
43
|
+
taskSpecs,
|
|
44
|
+
states,
|
|
45
|
+
selectionLimit: options.limit ?? graph.nodes.length,
|
|
46
|
+
});
|
|
47
|
+
const limitedTaskIds = plan.selected.map((candidate) => candidate.taskId);
|
|
40
48
|
const tasks = [];
|
|
41
49
|
const runner = options.runTask ?? runTaskSpec;
|
|
42
50
|
const progress = options.progress ?? noopProgressReporter;
|
|
43
51
|
const total = limitedTaskIds.length;
|
|
44
52
|
let index = 0;
|
|
45
53
|
const batchStartedAt = Date.now();
|
|
54
|
+
const readyPlanPath = path.join(getTaskPoolRoot(options.repoRoot), "artifacts", batchRunId, "ready-plan.json");
|
|
55
|
+
await mkdir(path.dirname(readyPlanPath), { recursive: true });
|
|
56
|
+
await writeFile(readyPlanPath, `${JSON.stringify(plan, null, 2)}\n`, "utf-8");
|
|
46
57
|
emit(progress, {
|
|
47
58
|
type: "readyQueue.computed",
|
|
48
59
|
source: "worker",
|
|
@@ -61,7 +72,10 @@ export async function runReadyTasks(options) {
|
|
|
61
72
|
index += 1;
|
|
62
73
|
const node = graph.nodes.find((candidate) => candidate.id === taskId);
|
|
63
74
|
const taskSpecPath = path.join(options.featureDir, "tasks", node?.task ?? `${taskId}.yaml`);
|
|
64
|
-
const taskSpec = await loadTaskSpec(taskSpecPath);
|
|
75
|
+
const taskSpec = taskSpecs.get(taskId) ?? await loadTaskSpec(taskSpecPath);
|
|
76
|
+
if (taskSpec.feature_id !== featureId) {
|
|
77
|
+
throw new Error(`task ${taskId} feature_id ${taskSpec.feature_id} does not match graph feature_id ${featureId}`);
|
|
78
|
+
}
|
|
65
79
|
const retryOfWorkerRunId = states[taskId]?.retryOfWorkerRunId;
|
|
66
80
|
let workerRunId = retryOfWorkerRunId
|
|
67
81
|
? buildRetryWorkerRunId(taskId, retryOfWorkerRunId, now)
|
|
@@ -69,14 +83,14 @@ export async function runReadyTasks(options) {
|
|
|
69
83
|
buildStableWorkerRunId(taskId, taskSpec, now, controllerIdentity);
|
|
70
84
|
if (workerRunId) {
|
|
71
85
|
let existing = await findRunByWorkerRunId(options.repoRoot, workerRunId);
|
|
72
|
-
if (existing && !
|
|
86
|
+
if (existing && !runCanBeReused(existing, featureId, taskId, controllerIdentity)) {
|
|
73
87
|
workerRunId = buildControllerScopedWorkerRunId(workerRunId, controllerIdentity);
|
|
74
88
|
existing = await findRunByWorkerRunId(options.repoRoot, workerRunId);
|
|
75
|
-
if (existing && !
|
|
76
|
-
throw new Error(`workerRunId collision has incompatible controller identity: ${workerRunId}`);
|
|
89
|
+
if (existing && !runCanBeReused(existing, featureId, taskId, controllerIdentity)) {
|
|
90
|
+
throw new Error(`workerRunId collision has incompatible feature/controller identity: ${workerRunId}`);
|
|
77
91
|
}
|
|
78
92
|
}
|
|
79
|
-
if (existing) {
|
|
93
|
+
if (existing && runCanBeReused(existing, featureId, taskId, controllerIdentity)) {
|
|
80
94
|
progress.task(`task ${index}/${total} ${taskId}: reuse existing run ${workerRunId}`);
|
|
81
95
|
emit(progress, {
|
|
82
96
|
type: "task.reused",
|
|
@@ -112,6 +126,8 @@ export async function runReadyTasks(options) {
|
|
|
112
126
|
let result;
|
|
113
127
|
try {
|
|
114
128
|
await writeTaskPoolState(options.repoRoot, {
|
|
129
|
+
schemaVersion: 2,
|
|
130
|
+
featureId,
|
|
115
131
|
taskId,
|
|
116
132
|
status: "Running",
|
|
117
133
|
updatedAt: new Date().toISOString(),
|
|
@@ -277,6 +293,12 @@ export async function runReadyTasks(options) {
|
|
|
277
293
|
summary,
|
|
278
294
|
tasks,
|
|
279
295
|
batchRunPath,
|
|
296
|
+
readyPlanPath,
|
|
297
|
+
selectedTaskIds: limitedTaskIds,
|
|
298
|
+
deferredTaskIds: plan.deferred.map((candidate) => candidate.taskId),
|
|
299
|
+
orderingPolicy: plan.orderingPolicy,
|
|
300
|
+
selectionLimit: plan.selectionLimit,
|
|
301
|
+
effectiveConcurrency: plan.effectiveConcurrency,
|
|
280
302
|
...(controllerIdentity ? { controllerIdentity } : {}),
|
|
281
303
|
};
|
|
282
304
|
await mkdir(path.dirname(batchRunPath), { recursive: true });
|
|
@@ -345,7 +367,9 @@ function buildControllerScopedWorkerRunId(baseWorkerRunId, controllerIdentity) {
|
|
|
345
367
|
.slice(0, 10);
|
|
346
368
|
return `${baseWorkerRunId}-controller-${hash}`;
|
|
347
369
|
}
|
|
348
|
-
function
|
|
370
|
+
function runCanBeReused(existing, featureId, taskId, controllerIdentity) {
|
|
371
|
+
if (existing.featureId !== featureId || existing.taskId !== taskId)
|
|
372
|
+
return false;
|
|
349
373
|
if (!existing.controllerIdentity && !controllerIdentity)
|
|
350
374
|
return true;
|
|
351
375
|
return controllerIdentitiesMatch(existing.controllerIdentity, controllerIdentity);
|
|
@@ -370,22 +394,13 @@ async function loadTaskGraph(featureDir) {
|
|
|
370
394
|
async function loadTaskSpec(taskSpecPath) {
|
|
371
395
|
return taskSpecSchema.parse(YAML.parse(await readFile(taskSpecPath, "utf-8")));
|
|
372
396
|
}
|
|
373
|
-
function
|
|
374
|
-
const
|
|
375
|
-
for (const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
if (state.status === "Running" || state.status === "Queued")
|
|
379
|
-
graphState[taskId] = "running";
|
|
380
|
-
if (state.status === "Failed")
|
|
381
|
-
graphState[taskId] = "failed";
|
|
382
|
-
if (state.status === "Blocked")
|
|
383
|
-
graphState[taskId] = "blocked";
|
|
397
|
+
async function loadFeatureTaskSpecs(featureDir, graph) {
|
|
398
|
+
const specs = new Map();
|
|
399
|
+
for (const node of graph.nodes) {
|
|
400
|
+
const taskSpec = await loadTaskSpec(path.join(featureDir, "tasks", node.task ?? `${node.id}.yaml`));
|
|
401
|
+
specs.set(node.id, taskSpec);
|
|
384
402
|
}
|
|
385
|
-
return
|
|
386
|
-
}
|
|
387
|
-
function isPoolReady(state) {
|
|
388
|
-
return !state || state.status === "Ready";
|
|
403
|
+
return specs;
|
|
389
404
|
}
|
|
390
405
|
function summarize(tasks) {
|
|
391
406
|
return {
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
const OWN_STATUS_REASON = {
|
|
2
|
+
Draft: "task-draft",
|
|
3
|
+
Queued: "task-queued",
|
|
4
|
+
Running: "task-running",
|
|
5
|
+
AgentCompleted: "task-agent-completed",
|
|
6
|
+
VerificationRunning: "task-verification-running",
|
|
7
|
+
HumanReview: "task-human-review",
|
|
8
|
+
Done: "task-done",
|
|
9
|
+
Failed: "task-failed",
|
|
10
|
+
Blocked: "task-blocked",
|
|
11
|
+
Abandoned: "task-abandoned",
|
|
12
|
+
};
|
|
13
|
+
/** Contract severity: failed > blocked > not-completed > missing-state. */
|
|
14
|
+
function dependencyReason(unmet) {
|
|
15
|
+
if (unmet.some((item) => item.state === "Failed"))
|
|
16
|
+
return "dependency-failed";
|
|
17
|
+
if (unmet.some((item) => item.state === "Blocked"))
|
|
18
|
+
return "dependency-blocked";
|
|
19
|
+
if (unmet.some((item) => item.state !== undefined))
|
|
20
|
+
return "dependency-not-completed";
|
|
21
|
+
return "dependency-missing-state";
|
|
22
|
+
}
|
|
23
|
+
function ownReason(status) {
|
|
24
|
+
if (status === "Ready")
|
|
25
|
+
return undefined;
|
|
26
|
+
return OWN_STATUS_REASON[status] ?? "task-state-invalid";
|
|
27
|
+
}
|
|
28
|
+
export function planReadyTasks(input) {
|
|
29
|
+
if (!Number.isInteger(input.selectionLimit) || input.selectionLimit < 1) {
|
|
30
|
+
throw new Error("selectionLimit must be a positive integer");
|
|
31
|
+
}
|
|
32
|
+
const eligible = [];
|
|
33
|
+
const blocked = [];
|
|
34
|
+
for (const [graphIndex, node] of input.graph.nodes.entries()) {
|
|
35
|
+
const spec = input.taskSpecs.get(node.id);
|
|
36
|
+
if (!spec) {
|
|
37
|
+
blocked.push({
|
|
38
|
+
featureId: input.featureId,
|
|
39
|
+
taskId: node.id,
|
|
40
|
+
reasonCode: "task-spec-missing",
|
|
41
|
+
reason: "TaskSpec is missing",
|
|
42
|
+
blockedBy: [],
|
|
43
|
+
});
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (spec.id !== node.id || spec.type !== node.type || !same(spec.depends_on, node.depends_on)) {
|
|
47
|
+
blocked.push({
|
|
48
|
+
featureId: input.featureId,
|
|
49
|
+
taskId: node.id,
|
|
50
|
+
priority: spec.priority,
|
|
51
|
+
reasonCode: "task-spec-mismatch",
|
|
52
|
+
reason: "TaskSpec does not match task graph",
|
|
53
|
+
blockedBy: [],
|
|
54
|
+
});
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const state = input.states[node.id];
|
|
58
|
+
if (state) {
|
|
59
|
+
const own = ownReason(state.status);
|
|
60
|
+
if (own) {
|
|
61
|
+
blocked.push({
|
|
62
|
+
featureId: input.featureId,
|
|
63
|
+
taskId: node.id,
|
|
64
|
+
priority: spec.priority,
|
|
65
|
+
reasonCode: own,
|
|
66
|
+
reason: own === "task-state-invalid"
|
|
67
|
+
? `task status is invalid: ${String(state.status)}`
|
|
68
|
+
: `task status is ${state.status}`,
|
|
69
|
+
blockedBy: [],
|
|
70
|
+
currentStatus: state.status,
|
|
71
|
+
});
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const deps = node.depends_on.map((id) => ({ id, state: input.states[id]?.status }));
|
|
76
|
+
const unmet = deps.filter(({ state: depState }) => depState !== "Done");
|
|
77
|
+
if (unmet.length) {
|
|
78
|
+
blocked.push({
|
|
79
|
+
featureId: input.featureId,
|
|
80
|
+
taskId: node.id,
|
|
81
|
+
priority: spec.priority,
|
|
82
|
+
reasonCode: dependencyReason(unmet),
|
|
83
|
+
reason: "dependencies are not completed",
|
|
84
|
+
blockedBy: unmet.map((item) => item.id),
|
|
85
|
+
});
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
eligible.push({
|
|
89
|
+
featureId: input.featureId,
|
|
90
|
+
taskId: node.id,
|
|
91
|
+
title: spec.title,
|
|
92
|
+
type: spec.type,
|
|
93
|
+
priority: spec.priority,
|
|
94
|
+
riskLevel: spec.risk_level,
|
|
95
|
+
graphIndex,
|
|
96
|
+
reason: "dependencies-satisfied",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
eligible.sort((a, b) => priority(a.priority) - priority(b.priority) ||
|
|
100
|
+
a.graphIndex - b.graphIndex ||
|
|
101
|
+
ascii(a.taskId, b.taskId));
|
|
102
|
+
const selected = eligible.slice(0, input.selectionLimit);
|
|
103
|
+
const deferred = eligible.slice(input.selectionLimit).map((candidate) => ({
|
|
104
|
+
...candidate,
|
|
105
|
+
reasonCode: "selection-limit",
|
|
106
|
+
selectedAhead: selected.map((item) => item.taskId),
|
|
107
|
+
}));
|
|
108
|
+
return {
|
|
109
|
+
schemaVersion: 1,
|
|
110
|
+
featureId: input.featureId,
|
|
111
|
+
executionMode: "serial",
|
|
112
|
+
effectiveConcurrency: 1,
|
|
113
|
+
selectionLimit: input.selectionLimit,
|
|
114
|
+
orderingPolicy: ["priority:P0>P1>P2>P3", "graph-order", "task-id"],
|
|
115
|
+
eligible,
|
|
116
|
+
blocked,
|
|
117
|
+
selected,
|
|
118
|
+
deferred,
|
|
119
|
+
summary: {
|
|
120
|
+
total: input.graph.nodes.length,
|
|
121
|
+
eligible: eligible.length,
|
|
122
|
+
blocked: blocked.length,
|
|
123
|
+
selected: selected.length,
|
|
124
|
+
deferred: deferred.length,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function priority(value) {
|
|
129
|
+
return { P0: 0, P1: 1, P2: 2, P3: 3 }[value];
|
|
130
|
+
}
|
|
131
|
+
function ascii(a, b) {
|
|
132
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
133
|
+
}
|
|
134
|
+
function same(a, b) {
|
|
135
|
+
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
136
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
|
|
5
|
+
import { controllerIdentitiesMatch, resolveRunningControllerIdentity, } from "../../shared/package-metadata.js";
|
|
6
|
+
export const CONTROLLER_IDENTITY_ARTIFACT_NAME = "controller-identity.json";
|
|
7
|
+
function hashArtifact(artifact) {
|
|
8
|
+
return createHash("sha256")
|
|
9
|
+
.update(`${JSON.stringify(artifact, null, 2)}\n`)
|
|
10
|
+
.digest("hex");
|
|
11
|
+
}
|
|
12
|
+
function summaryFields(identity) {
|
|
13
|
+
if (!identity)
|
|
14
|
+
return {};
|
|
15
|
+
return {
|
|
16
|
+
packageName: identity.packageName,
|
|
17
|
+
packageVersion: identity.packageVersion,
|
|
18
|
+
packageFingerprint: identity.packageFingerprint.value,
|
|
19
|
+
binarySha256: identity.binarySha256,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Capture the running controller identity as a run-owned fact and persist it
|
|
24
|
+
* under `runDir/controller-identity.json`. Returns the ref (with content hash)
|
|
25
|
+
* to store on DagRunState. When the identity cannot be resolved, records a
|
|
26
|
+
* `legacy-unpinned` artifact instead of fabricating an identity.
|
|
27
|
+
*/
|
|
28
|
+
export async function captureControllerIdentity(input) {
|
|
29
|
+
const capturedAt = (input.now ?? new Date()).toISOString();
|
|
30
|
+
const identity = input.identity !== undefined
|
|
31
|
+
? input.identity
|
|
32
|
+
: (resolveRunningControllerIdentity({
|
|
33
|
+
...(input.argv ? { argv: input.argv } : {}),
|
|
34
|
+
...(input.now ? { now: input.now } : {}),
|
|
35
|
+
}) ?? null);
|
|
36
|
+
const artifact = identity
|
|
37
|
+
? { schemaVersion: 1, status: "pinned", capturedAt, identity }
|
|
38
|
+
: {
|
|
39
|
+
schemaVersion: 1,
|
|
40
|
+
status: "legacy-unpinned",
|
|
41
|
+
capturedAt,
|
|
42
|
+
identity: null,
|
|
43
|
+
reason: "running controller identity could not be resolved at run creation",
|
|
44
|
+
};
|
|
45
|
+
await writeJsonAtomic(path.join(input.runDir, CONTROLLER_IDENTITY_ARTIFACT_NAME), artifact);
|
|
46
|
+
return {
|
|
47
|
+
schemaVersion: 1,
|
|
48
|
+
path: CONTROLLER_IDENTITY_ARTIFACT_NAME,
|
|
49
|
+
sha256: hashArtifact(artifact),
|
|
50
|
+
capturedAt,
|
|
51
|
+
status: artifact.status,
|
|
52
|
+
...summaryFields(identity),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export async function readControllerIdentityArtifact(runDir) {
|
|
56
|
+
try {
|
|
57
|
+
const raw = await readFile(path.join(runDir, CONTROLLER_IDENTITY_ARTIFACT_NAME), "utf-8");
|
|
58
|
+
return JSON.parse(raw);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function shortIdentity(identity) {
|
|
65
|
+
return `${identity.packageName}@${identity.packageVersion} (fingerprint ${identity.packageFingerprint.value}, binary ${identity.binarySha256.slice(0, 12)})`;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Recompute the current controller identity and compare it to the pinned run
|
|
69
|
+
* fact before resuming. Fails closed on drift; reports (without inventing an
|
|
70
|
+
* identity). Legacy/unpinned runs remain readable but cannot be resumed.
|
|
71
|
+
*/
|
|
72
|
+
export async function verifyControllerIdentityForResume(input) {
|
|
73
|
+
const ref = input.state.controllerIdentityRef;
|
|
74
|
+
if (!ref || ref.status === "legacy-unpinned") {
|
|
75
|
+
throw new Error(`dag run ${input.state.runId} has no pinned controller identity; legacy/unpinned runs remain readable but cannot be resumed safely`);
|
|
76
|
+
}
|
|
77
|
+
const artifact = await readControllerIdentityArtifact(input.runDir);
|
|
78
|
+
if (!artifact) {
|
|
79
|
+
throw new Error(`dag run ${input.state.runId} is pinned but ${CONTROLLER_IDENTITY_ARTIFACT_NAME} is missing; refuse to resume`);
|
|
80
|
+
}
|
|
81
|
+
const persistedHash = createHash("sha256")
|
|
82
|
+
.update(`${JSON.stringify(artifact, null, 2)}\n`)
|
|
83
|
+
.digest("hex");
|
|
84
|
+
if (persistedHash !== ref.sha256) {
|
|
85
|
+
throw new Error(`dag run ${input.state.runId} controller identity artifact was tampered with (hash mismatch); refuse to resume`);
|
|
86
|
+
}
|
|
87
|
+
const pinned = artifact.identity;
|
|
88
|
+
if (!pinned) {
|
|
89
|
+
throw new Error(`dag run ${input.state.runId} is pinned but the identity artifact has no identity payload; refuse to resume`);
|
|
90
|
+
}
|
|
91
|
+
const current = input.currentIdentity !== undefined
|
|
92
|
+
? input.currentIdentity
|
|
93
|
+
: (resolveRunningControllerIdentity({
|
|
94
|
+
...(input.argv ? { argv: input.argv } : {}),
|
|
95
|
+
...(input.now ? { now: input.now } : {}),
|
|
96
|
+
}) ?? null);
|
|
97
|
+
if (!current) {
|
|
98
|
+
throw new Error(`dag run ${input.state.runId} was pinned to ${shortIdentity(pinned)} but the current controller identity could not be resolved; refuse to resume`);
|
|
99
|
+
}
|
|
100
|
+
if (!controllerIdentitiesMatch(pinned, current)) {
|
|
101
|
+
throw new Error(`dag run ${input.state.runId} controller identity drifted; pinned=${shortIdentity(pinned)} current=${shortIdentity(current)}; start a new run instead of resuming`);
|
|
102
|
+
}
|
|
103
|
+
return { status: "pinned-match", identity: current };
|
|
104
|
+
}
|
|
@@ -2,7 +2,7 @@ import { access, appendFile, cp, mkdir } from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parseProcessVerdict } from "../node-execution.js";
|
|
4
4
|
import { freshNodeRecord } from "../dynamic-runtime/shared.js";
|
|
5
|
-
const
|
|
5
|
+
const DEFAULT_CONVERGENCE_CHAIN_NODE_IDS = [
|
|
6
6
|
"process-supervisor-pi",
|
|
7
7
|
"process-gate-shell",
|
|
8
8
|
"repair-pi",
|
|
@@ -29,7 +29,7 @@ export async function runConvergencePassController(input) {
|
|
|
29
29
|
convergence.terminalReason = "feature-flag-off";
|
|
30
30
|
return { retry: false };
|
|
31
31
|
}
|
|
32
|
-
if (!hasConvergenceChain(input.tasksById)) {
|
|
32
|
+
if (!hasConvergenceChain(input.tasksById, input.spec)) {
|
|
33
33
|
convergence.terminalReason = "unsupported-dag-shape";
|
|
34
34
|
return { retry: false };
|
|
35
35
|
}
|
|
@@ -54,6 +54,7 @@ export async function runConvergencePassController(input) {
|
|
|
54
54
|
reason: "hard-verify-failed",
|
|
55
55
|
state: input.state,
|
|
56
56
|
runDir: input.runDir,
|
|
57
|
+
spec: input.spec,
|
|
57
58
|
});
|
|
58
59
|
if (CONVERGENCE_NON_RETRY_FAILURES.has(hardFailure)) {
|
|
59
60
|
passRecord.status = "terminal";
|
|
@@ -96,8 +97,12 @@ export async function runConvergencePassController(input) {
|
|
|
96
97
|
await input.persistState();
|
|
97
98
|
return { retry: true };
|
|
98
99
|
}
|
|
99
|
-
function
|
|
100
|
-
return
|
|
100
|
+
function getConvergenceChain(spec) {
|
|
101
|
+
return spec.convergence?.chainNodeIds ?? DEFAULT_CONVERGENCE_CHAIN_NODE_IDS;
|
|
102
|
+
}
|
|
103
|
+
function hasConvergenceChain(tasksById, spec) {
|
|
104
|
+
const chain = getConvergenceChain(spec);
|
|
105
|
+
return chain.every((id) => tasksById.has(id));
|
|
101
106
|
}
|
|
102
107
|
async function buildConvergencePassRecord(input) {
|
|
103
108
|
const hardVerify = input.state.nodes["hard-verify-shell"];
|
|
@@ -122,13 +127,14 @@ async function buildConvergencePassRecord(input) {
|
|
|
122
127
|
pass: input.pass,
|
|
123
128
|
state: input.state,
|
|
124
129
|
runDir: input.runDir,
|
|
130
|
+
spec: input.spec,
|
|
125
131
|
}),
|
|
126
132
|
};
|
|
127
133
|
}
|
|
128
134
|
async function preserveConvergencePassArtifacts(input) {
|
|
129
135
|
const passDir = path.join(input.runDir, "convergence", `pass-${input.pass}`);
|
|
130
136
|
await mkdir(passDir, { recursive: true });
|
|
131
|
-
const nodeIds = nodesToPreserveForConvergence(input.state);
|
|
137
|
+
const nodeIds = nodesToPreserveForConvergence(input.state, input.spec);
|
|
132
138
|
const refs = [];
|
|
133
139
|
for (const nodeId of nodeIds) {
|
|
134
140
|
const node = input.state.nodes[nodeId];
|
|
@@ -151,8 +157,9 @@ async function preserveConvergencePassArtifacts(input) {
|
|
|
151
157
|
}
|
|
152
158
|
return refs;
|
|
153
159
|
}
|
|
154
|
-
function nodesToPreserveForConvergence(state) {
|
|
155
|
-
|
|
160
|
+
function nodesToPreserveForConvergence(state, spec) {
|
|
161
|
+
const chain = getConvergenceChain(spec);
|
|
162
|
+
return chain.filter((nodeId) => state.nodes[nodeId]);
|
|
156
163
|
}
|
|
157
164
|
async function copyIfExists(from, to) {
|
|
158
165
|
try {
|
|
@@ -242,7 +249,8 @@ function extractSupervisorStructuredBlock(node) {
|
|
|
242
249
|
return block;
|
|
243
250
|
}
|
|
244
251
|
async function resetConvergenceNodesForNextPass(input) {
|
|
245
|
-
const
|
|
252
|
+
const chain = getConvergenceChain(input.spec);
|
|
253
|
+
const resetIds = new Set(chain);
|
|
246
254
|
for (const id of collectTransitiveDescendantTaskIds(input.spec, "hard-verify-shell")) {
|
|
247
255
|
const node = input.state.nodes[id];
|
|
248
256
|
if (node?.status === "SKIPPED")
|