@tea-agent/loop-agent 0.34.0 → 0.34.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/dist/application/task-lifecycle/advance.js +57 -7
- package/dist/application/task-lifecycle/plan-transitions.js +13 -21
- package/dist/executors/dag-pi-executor.js +18 -3
- package/dist/shared/operator/capabilities.js +26 -2
- package/dist/shared/timeout-policy.js +5 -0
- package/dist/task/source-prepare/prepare.js +49 -2
- package/dist/worker/cli.js +16 -0
- package/dist/worker/console/interview/grill-me.js +7 -6
- package/dist/worker/console/operator-actions.js +231 -22
- package/dist/worker/console/prd-intake-bridge.js +393 -0
- package/dist/worker/console/recovery-cta.js +35 -6
- package/dist/worker/console/server.js +28 -0
- package/dist/worker/console/static/assets/index-BpuHmlSP.js +29 -0
- package/dist/worker/console/static/index.html +1 -1
- package/dist/worker/console/static-src/app/console-types.js +1 -0
- package/dist/worker/console/static-src/app/usePrdImport.js +2 -1
- package/dist/worker/console/static-src/app/useRecoveryActions.js +24 -1
- package/dist/worker/console/static-src/app/useRecoveryConsole.js +19 -1
- package/dist/worker/console/static-src/app/useTaskWizard.js +57 -2
- package/dist/worker/observability/read-model.js +3 -0
- package/dist/workflows/dag/failure-category.js +3 -0
- package/dist/workflows/dag/init-hybrid.js +15 -9
- package/dist/workflows/dag/liveness-policy.js +2 -1
- package/dist/workflows/dag/node-execution.js +3 -2
- package/dist/workflows/dag/retry-policy.js +44 -11
- package/dist/workflows/dag/runner.js +6 -0
- package/dist/workflows/dag/scheduler.js +49 -1
- package/dist/workflows/dag/validate.js +16 -2
- package/docs/templates/agent-dag.schema.json +4 -4
- package/package.json +1 -1
- package/dist/worker/console/static/assets/index-CMHovlqG.js +0 -32
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console PRD Intake Bridge (design 2026-08-11-console-prd-intake-bridge).
|
|
3
|
+
* Maps task-side intake artifacts / projected source into Console DraftStore
|
|
4
|
+
* fields. Does not run Semantic Intake itself — that stays in task advance.
|
|
5
|
+
*/
|
|
6
|
+
import { access, readFile } from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { getTaskPaths } from "../../task/runtime.js";
|
|
9
|
+
import { SEMANTIC_INTAKE_ARTIFACT_REL, } from "../../task/source-prepare/semantic-intake.js";
|
|
10
|
+
import { extractRequirementFactsFromMarkdown } from "../../task/source-prepare/parse-intent.js";
|
|
11
|
+
import { emptyDraft } from "./interview/grill-me.js";
|
|
12
|
+
import { normalizeConsoleWorkflowKind } from "./workflow-kinds.js";
|
|
13
|
+
function asStringList(value) {
|
|
14
|
+
if (!Array.isArray(value))
|
|
15
|
+
return [];
|
|
16
|
+
return value
|
|
17
|
+
.map((item) => {
|
|
18
|
+
if (typeof item === "string")
|
|
19
|
+
return item.trim();
|
|
20
|
+
if (item && typeof item === "object") {
|
|
21
|
+
const record = item;
|
|
22
|
+
if (typeof record.text === "string")
|
|
23
|
+
return record.text.trim();
|
|
24
|
+
if (typeof record.message === "string")
|
|
25
|
+
return record.message.trim();
|
|
26
|
+
}
|
|
27
|
+
return String(item ?? "").trim();
|
|
28
|
+
})
|
|
29
|
+
.filter(Boolean);
|
|
30
|
+
}
|
|
31
|
+
function asAcceptance(value) {
|
|
32
|
+
if (!Array.isArray(value))
|
|
33
|
+
return [];
|
|
34
|
+
const out = [];
|
|
35
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
36
|
+
const item = value[i];
|
|
37
|
+
if (typeof item === "string" && item.trim()) {
|
|
38
|
+
out.push({ id: `AC-${i + 1}`, text: item.trim() });
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (item && typeof item === "object") {
|
|
42
|
+
const record = item;
|
|
43
|
+
const text = typeof record.text === "string" ? record.text.trim() : "";
|
|
44
|
+
if (!text)
|
|
45
|
+
continue;
|
|
46
|
+
const id = typeof record.id === "string" && record.id.trim()
|
|
47
|
+
? record.id.trim()
|
|
48
|
+
: `AC-${i + 1}`;
|
|
49
|
+
out.push({ id, text });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
function warningList(json) {
|
|
55
|
+
if (!json || typeof json !== "object")
|
|
56
|
+
return [];
|
|
57
|
+
const root = json;
|
|
58
|
+
const raw = Array.isArray(root.warnings)
|
|
59
|
+
? root.warnings
|
|
60
|
+
: Array.isArray(root.result?.warnings)
|
|
61
|
+
? root.result?.warnings
|
|
62
|
+
: [];
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const item of raw) {
|
|
65
|
+
if (!item || typeof item !== "object")
|
|
66
|
+
continue;
|
|
67
|
+
const record = item;
|
|
68
|
+
const code = typeof record.code === "string" && record.code.trim()
|
|
69
|
+
? record.code.trim()
|
|
70
|
+
: "UNKNOWN";
|
|
71
|
+
const message = typeof record.message === "string" ? record.message : code;
|
|
72
|
+
out.push({ code, message });
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
function lifecycleFromAdvanceJson(json) {
|
|
77
|
+
if (!json || typeof json !== "object")
|
|
78
|
+
return undefined;
|
|
79
|
+
const root = json;
|
|
80
|
+
const value = root.result?.lifecycleState ?? root.lifecycleState;
|
|
81
|
+
return typeof value === "string" ? value : undefined;
|
|
82
|
+
}
|
|
83
|
+
async function readJsonIfExists(filePath) {
|
|
84
|
+
try {
|
|
85
|
+
await access(filePath);
|
|
86
|
+
const raw = await readFile(filePath, "utf8");
|
|
87
|
+
return JSON.parse(raw);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function hasRequirementStructure(draft) {
|
|
94
|
+
const req = draft.requirement ?? {};
|
|
95
|
+
return Boolean(typeof req.objective === "string" &&
|
|
96
|
+
req.objective.trim() &&
|
|
97
|
+
Array.isArray(req.scope) &&
|
|
98
|
+
req.scope.length > 0 &&
|
|
99
|
+
Array.isArray(req.acceptanceCriteria) &&
|
|
100
|
+
req.acceptanceCriteria.length > 0);
|
|
101
|
+
}
|
|
102
|
+
/** Normalize path / verify lists from operator params (arrays or newline/comma text). */
|
|
103
|
+
export function parseEngineeringBoundaryFromParams(p) {
|
|
104
|
+
const asList = (value) => {
|
|
105
|
+
if (Array.isArray(value)) {
|
|
106
|
+
return value
|
|
107
|
+
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
|
108
|
+
.filter(Boolean);
|
|
109
|
+
}
|
|
110
|
+
if (typeof value === "string") {
|
|
111
|
+
return value
|
|
112
|
+
.split(/[\n,;,;]+/)
|
|
113
|
+
.map((s) => s.trim())
|
|
114
|
+
.filter(Boolean);
|
|
115
|
+
}
|
|
116
|
+
return [];
|
|
117
|
+
};
|
|
118
|
+
const allowedPaths = asList(p.allowedPaths ?? p.allowedPath);
|
|
119
|
+
const forbiddenPaths = asList(p.forbiddenPaths ?? p.forbiddenPath);
|
|
120
|
+
const verifyRaw = p.verifyCommands ?? p.verify;
|
|
121
|
+
const verifyCommands = [];
|
|
122
|
+
if (Array.isArray(verifyRaw)) {
|
|
123
|
+
for (let i = 0; i < verifyRaw.length; i += 1) {
|
|
124
|
+
const item = verifyRaw[i];
|
|
125
|
+
if (typeof item === "string" && item.trim()) {
|
|
126
|
+
const trimmed = item.trim();
|
|
127
|
+
const idx = trimmed.indexOf(":");
|
|
128
|
+
if (idx > 0 && idx < trimmed.length - 1 && !trimmed.slice(0, idx).includes(" ")) {
|
|
129
|
+
verifyCommands.push({
|
|
130
|
+
label: trimmed.slice(0, idx),
|
|
131
|
+
command: trimmed.slice(idx + 1).trim(),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
verifyCommands.push({
|
|
136
|
+
label: `verify-${i + 1}`,
|
|
137
|
+
command: trimmed,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
else if (item && typeof item === "object") {
|
|
142
|
+
const rec = item;
|
|
143
|
+
const command = typeof rec.command === "string" ? rec.command.trim() : "";
|
|
144
|
+
if (!command)
|
|
145
|
+
continue;
|
|
146
|
+
verifyCommands.push({
|
|
147
|
+
label: typeof rec.label === "string" && rec.label.trim()
|
|
148
|
+
? rec.label.trim()
|
|
149
|
+
: `verify-${i + 1}`,
|
|
150
|
+
command,
|
|
151
|
+
...(typeof rec.timeoutMs === "number"
|
|
152
|
+
? { timeoutMs: rec.timeoutMs }
|
|
153
|
+
: {}),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
else if (typeof verifyRaw === "string" && verifyRaw.trim()) {
|
|
159
|
+
for (const [i, line] of asList(verifyRaw).entries()) {
|
|
160
|
+
const idx = line.indexOf(":");
|
|
161
|
+
if (idx > 0 && idx < line.length - 1 && !line.slice(0, idx).includes(" ")) {
|
|
162
|
+
verifyCommands.push({
|
|
163
|
+
label: line.slice(0, idx),
|
|
164
|
+
command: line.slice(idx + 1).trim(),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
verifyCommands.push({ label: `verify-${i + 1}`, command: line });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
...(allowedPaths.length > 0 ? { allowedPaths } : {}),
|
|
174
|
+
...(forbiddenPaths.length > 0 ? { forbiddenPaths } : {}),
|
|
175
|
+
...(verifyCommands.length > 0 ? { verifyCommands } : {}),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
export function appendEngineeringCliArgs(args, boundary) {
|
|
179
|
+
for (const pathGlob of boundary.allowedPaths ?? []) {
|
|
180
|
+
args.push("--allowed-path", pathGlob);
|
|
181
|
+
}
|
|
182
|
+
for (const pathGlob of boundary.forbiddenPaths ?? []) {
|
|
183
|
+
args.push("--forbidden-path", pathGlob);
|
|
184
|
+
}
|
|
185
|
+
for (const cmd of boundary.verifyCommands ?? []) {
|
|
186
|
+
args.push("--verify", `${cmd.label}:${cmd.command}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
export function applyEngineeringBoundaryToDraft(draft, boundary) {
|
|
190
|
+
if (!(boundary.allowedPaths?.length) &&
|
|
191
|
+
!(boundary.forbiddenPaths?.length) &&
|
|
192
|
+
!(boundary.verifyCommands?.length)) {
|
|
193
|
+
return draft;
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
...draft,
|
|
197
|
+
constraints: {
|
|
198
|
+
invariants: draft.constraints?.invariants ?? [],
|
|
199
|
+
allowedPaths: boundary.allowedPaths?.length
|
|
200
|
+
? boundary.allowedPaths
|
|
201
|
+
: (draft.constraints?.allowedPaths ?? []),
|
|
202
|
+
forbiddenPaths: boundary.forbiddenPaths?.length
|
|
203
|
+
? boundary.forbiddenPaths
|
|
204
|
+
: (draft.constraints?.forbiddenPaths ?? []),
|
|
205
|
+
},
|
|
206
|
+
verification: {
|
|
207
|
+
commands: boundary.verifyCommands?.length
|
|
208
|
+
? boundary.verifyCommands
|
|
209
|
+
: (draft.verification?.commands ?? []),
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
export async function buildDraftFromTaskIntake(input) {
|
|
214
|
+
const taskKind = normalizeConsoleWorkflowKind(input.taskKind, "standard");
|
|
215
|
+
const paths = getTaskPaths(input.repoRoot, input.taskId);
|
|
216
|
+
const warnings = warningList(input.advanceJson);
|
|
217
|
+
const lifecycleState = lifecycleFromAdvanceJson(input.advanceJson);
|
|
218
|
+
const semanticApplied = warnings.some((w) => w.code === "SEMANTIC_INTAKE_APPLIED");
|
|
219
|
+
const semanticFailed = warnings.find((w) => w.code === "SEMANTIC_INTAKE_PI_FAILED" ||
|
|
220
|
+
w.code === "SEMANTIC_INTAKE_INVALID_OUTPUT" ||
|
|
221
|
+
w.code === "SEMANTIC_INTAKE_FAILED" ||
|
|
222
|
+
w.code === "SEMANTIC_INTAKE_NO_DOCUMENTS" ||
|
|
223
|
+
w.code === "SEMANTIC_INTAKE_REFUSED_NON_EXECUTABLE");
|
|
224
|
+
const semanticSkipped = warnings.find((w) => w.code === "SEMANTIC_INTAKE_SKIPPED");
|
|
225
|
+
const artifactPath = path.join(paths.taskDir, SEMANTIC_INTAKE_ARTIFACT_REL);
|
|
226
|
+
const artifact = await readJsonIfExists(artifactPath);
|
|
227
|
+
let draft = {
|
|
228
|
+
...emptyDraft(input.taskId, input.title, { taskKind }),
|
|
229
|
+
requirement: {
|
|
230
|
+
objective: input.title,
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
let parseStatus = "pending";
|
|
234
|
+
let semanticIntake = {
|
|
235
|
+
applied: false,
|
|
236
|
+
code: semanticFailed?.code ?? semanticSkipped?.code,
|
|
237
|
+
message: semanticFailed?.message ?? semanticSkipped?.message,
|
|
238
|
+
};
|
|
239
|
+
const semanticReq = artifact?.draft?.requirement ??
|
|
240
|
+
(artifact?.semantic
|
|
241
|
+
? {
|
|
242
|
+
objective: artifact.semantic.objective,
|
|
243
|
+
scope: artifact.semantic.scope,
|
|
244
|
+
nonGoals: artifact.semantic.nonGoals,
|
|
245
|
+
acceptanceCriteria: artifact.semantic.acceptanceCriteria,
|
|
246
|
+
}
|
|
247
|
+
: undefined);
|
|
248
|
+
// An artifact is authoritative only when this exact advance invocation reports
|
|
249
|
+
// SEMANTIC_INTAKE_APPLIED. Tasks may import newer immutable PRD revisions while
|
|
250
|
+
// an older per-task semantic artifact remains on disk; never project it as the
|
|
251
|
+
// current Console draft.
|
|
252
|
+
if (semanticApplied && semanticReq?.objective?.trim()) {
|
|
253
|
+
const title = artifact?.draft?.title?.trim() ||
|
|
254
|
+
artifact?.semantic?.title?.trim() ||
|
|
255
|
+
input.title;
|
|
256
|
+
draft = {
|
|
257
|
+
...draft,
|
|
258
|
+
title,
|
|
259
|
+
requirement: {
|
|
260
|
+
objective: semanticReq.objective.trim(),
|
|
261
|
+
scope: asStringList(semanticReq.scope),
|
|
262
|
+
nonGoals: asStringList(semanticReq.nonGoals),
|
|
263
|
+
acceptanceCriteria: asAcceptance(semanticReq.acceptanceCriteria),
|
|
264
|
+
},
|
|
265
|
+
openQuestions: asStringList(artifact?.draft?.openQuestions ?? artifact?.semantic?.openQuestions) || undefined,
|
|
266
|
+
assumptions: asStringList(artifact?.draft?.assumptions ?? artifact?.semantic?.assumptions) || undefined,
|
|
267
|
+
references: artifact?.draft?.references,
|
|
268
|
+
};
|
|
269
|
+
parseStatus = "semantic";
|
|
270
|
+
semanticIntake = {
|
|
271
|
+
applied: true,
|
|
272
|
+
code: "SEMANTIC_INTAKE_APPLIED",
|
|
273
|
+
message: "structured from artifacts/intake/semantic-draft.json",
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
// Deterministic path. When the current intake is incomplete/failed/skipped,
|
|
278
|
+
// prefer the newest imported reference over an older projected 需求.md.
|
|
279
|
+
// Successful deterministic projection still prefers canonical 需求.md.
|
|
280
|
+
const projectedRequirementPath = path.join(paths.sourceDir, "需求.md");
|
|
281
|
+
const referenceCandidates = [];
|
|
282
|
+
try {
|
|
283
|
+
const manifestPath = path.join(paths.sourceDir, "source-manifest.json");
|
|
284
|
+
const manifest = await readJsonIfExists(manifestPath);
|
|
285
|
+
for (const doc of manifest?.documents ?? []) {
|
|
286
|
+
if ((doc.role === "requirement" || doc.role === "acceptance") &&
|
|
287
|
+
typeof doc.materializedPath === "string") {
|
|
288
|
+
referenceCandidates.push({
|
|
289
|
+
filePath: path.join(paths.sourceDir, doc.materializedPath),
|
|
290
|
+
importedAt: typeof doc.importedAt === "string" ? doc.importedAt : "",
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
// ignore manifest read errors
|
|
297
|
+
}
|
|
298
|
+
referenceCandidates.sort((left, right) => right.importedAt.localeCompare(left.importedAt));
|
|
299
|
+
const preferImportedReferences = Boolean(semanticFailed ||
|
|
300
|
+
semanticSkipped ||
|
|
301
|
+
lifecycleState === "intake-incomplete");
|
|
302
|
+
const candidates = preferImportedReferences
|
|
303
|
+
? [
|
|
304
|
+
...referenceCandidates.map((candidate) => candidate.filePath),
|
|
305
|
+
projectedRequirementPath,
|
|
306
|
+
]
|
|
307
|
+
: [
|
|
308
|
+
projectedRequirementPath,
|
|
309
|
+
...referenceCandidates.map((candidate) => candidate.filePath),
|
|
310
|
+
];
|
|
311
|
+
for (const filePath of candidates) {
|
|
312
|
+
try {
|
|
313
|
+
const markdown = await readFile(filePath, "utf8");
|
|
314
|
+
const facts = extractRequirementFactsFromMarkdown(markdown);
|
|
315
|
+
if (!facts.objective?.trim())
|
|
316
|
+
continue;
|
|
317
|
+
draft = {
|
|
318
|
+
...draft,
|
|
319
|
+
title: facts.title?.trim() || input.title,
|
|
320
|
+
requirement: {
|
|
321
|
+
objective: facts.objective.trim(),
|
|
322
|
+
scope: facts.scope,
|
|
323
|
+
nonGoals: facts.nonGoals,
|
|
324
|
+
acceptanceCriteria: facts.acceptanceCriteria,
|
|
325
|
+
},
|
|
326
|
+
openQuestions: facts.openQuestions.length > 0
|
|
327
|
+
? facts.openQuestions
|
|
328
|
+
: undefined,
|
|
329
|
+
assumptions: facts.assumptions.length > 0
|
|
330
|
+
? facts.assumptions
|
|
331
|
+
: undefined,
|
|
332
|
+
};
|
|
333
|
+
parseStatus = hasRequirementStructure(draft)
|
|
334
|
+
? "deterministic"
|
|
335
|
+
: "incomplete";
|
|
336
|
+
if (hasRequirementStructure(draft))
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
// try next candidate
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// Fallback: title-only objective (never invent scope/AC templates).
|
|
344
|
+
if (!draft.requirement?.objective?.trim()) {
|
|
345
|
+
draft = {
|
|
346
|
+
...draft,
|
|
347
|
+
requirement: { objective: input.title },
|
|
348
|
+
};
|
|
349
|
+
parseStatus = "incomplete";
|
|
350
|
+
}
|
|
351
|
+
else if (parseStatus === "pending") {
|
|
352
|
+
parseStatus = hasRequirementStructure(draft)
|
|
353
|
+
? "deterministic"
|
|
354
|
+
: "incomplete";
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// Engineering gaps remain as draft gaps; do not invent paths/AC templates.
|
|
358
|
+
const gaps = warnings.filter((w) => w.code === "EMPTY_ALLOWED_PATHS" ||
|
|
359
|
+
w.code === "EMPTY_ACCEPTANCE" ||
|
|
360
|
+
w.code === "EMPTY_OBJECTIVE" ||
|
|
361
|
+
w.code === "EMPTY_FORBIDDEN_PATHS" ||
|
|
362
|
+
w.code === "INTAKE_INCOMPLETE" ||
|
|
363
|
+
w.code === "BOUNDARY_REQUIRED" ||
|
|
364
|
+
w.code.startsWith("SEMANTIC_INTAKE_") ||
|
|
365
|
+
w.code === "NO_PARSEABLE_REQUIREMENT");
|
|
366
|
+
if (semanticFailed && !semanticIntake.applied) {
|
|
367
|
+
if (!hasRequirementStructure(draft)) {
|
|
368
|
+
parseStatus = "failed";
|
|
369
|
+
}
|
|
370
|
+
semanticIntake = {
|
|
371
|
+
applied: false,
|
|
372
|
+
code: semanticFailed.code,
|
|
373
|
+
message: semanticFailed.message,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
else if (!hasRequirementStructure(draft) && parseStatus !== "semantic") {
|
|
377
|
+
parseStatus = "incomplete";
|
|
378
|
+
}
|
|
379
|
+
if (input.engineering) {
|
|
380
|
+
draft = applyEngineeringBoundaryToDraft(draft, input.engineering);
|
|
381
|
+
}
|
|
382
|
+
return {
|
|
383
|
+
draft,
|
|
384
|
+
parseStatus,
|
|
385
|
+
semanticIntake,
|
|
386
|
+
gaps,
|
|
387
|
+
lifecycleState,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
/** True when requirement structure is filled enough that Skip should not invent AC/scope. */
|
|
391
|
+
export function requirementStructurallyReady(draft) {
|
|
392
|
+
return hasRequirementStructure(draft);
|
|
393
|
+
}
|
|
@@ -37,9 +37,10 @@ const CTA = {
|
|
|
37
37
|
id: "reconcile",
|
|
38
38
|
label: "对账 / reconcile",
|
|
39
39
|
requiresReason: true,
|
|
40
|
+
action: "dagReconcileRun",
|
|
40
41
|
lane: "dag",
|
|
41
|
-
guide: "
|
|
42
|
-
commandHint: "loop-agent dag reconcile-run --run-id <run-id> --reason <structured-reason>",
|
|
42
|
+
guide: "Orphan/中断时先对账归档(默认 supersede);runner 仍存活时勿盲 supersede",
|
|
43
|
+
commandHint: "loop-agent dag reconcile-run --run-id <run-id> --action supersede --reason <structured-reason>",
|
|
43
44
|
},
|
|
44
45
|
regenerate: {
|
|
45
46
|
id: "regenerate",
|
|
@@ -97,7 +98,9 @@ const MATRIX = {
|
|
|
97
98
|
"resume",
|
|
98
99
|
"regenerate",
|
|
99
100
|
],
|
|
100
|
-
|
|
101
|
+
// Stall/quiet/unknown: observe + doctor only — do not offer supersede while
|
|
102
|
+
// runner may still be alive (eligibility fail-closes, but UI should not tease).
|
|
103
|
+
"orphan-unknown": ["report", "doctor"],
|
|
101
104
|
"terminal-failed": [
|
|
102
105
|
"report",
|
|
103
106
|
"doctor",
|
|
@@ -137,8 +140,9 @@ export function partitionRecoveryCtas(ctas) {
|
|
|
137
140
|
}
|
|
138
141
|
/**
|
|
139
142
|
* Console only shows CTAs that actually dispatch an operator action.
|
|
140
|
-
* Pure CLI-hint rows (regenerate / decision / resume
|
|
141
|
-
*
|
|
143
|
+
* Pure CLI-hint rows (regenerate / decision / resume) stay in the policy
|
|
144
|
+
* matrix for docs, but must not appear as dead buttons. reconcile is wired
|
|
145
|
+
* to dagReconcileRun (structured reason required).
|
|
142
146
|
*/
|
|
143
147
|
export function isConsoleExecutableRecoveryCta(cta) {
|
|
144
148
|
return typeof cta.action === "string" && cta.action.trim().length > 0;
|
|
@@ -167,6 +171,8 @@ export function recommendedRecoveryCtaId(factClass) {
|
|
|
167
171
|
case "pause-on-human":
|
|
168
172
|
return pick("doctor", "report");
|
|
169
173
|
case "needs-reconcile":
|
|
174
|
+
// Prefer doctor first; reconcile is available when operator confirms reason.
|
|
175
|
+
return pick("doctor", "reconcile", "report");
|
|
170
176
|
case "runtime-mismatch":
|
|
171
177
|
case "contract-drift":
|
|
172
178
|
return pick("doctor", "report");
|
|
@@ -201,6 +207,7 @@ export function classifyRecoveryFact(input) {
|
|
|
201
207
|
const code = (input.errorCode ?? "").toLowerCase();
|
|
202
208
|
const state = (input.operationState ?? "").toLowerCase();
|
|
203
209
|
const dag = (input.dagStatus ?? "").toLowerCase();
|
|
210
|
+
const failure = (input.failureCategory ?? "").toLowerCase();
|
|
204
211
|
if (code.includes("validation") ||
|
|
205
212
|
code.includes("invalid") ||
|
|
206
213
|
state.includes("validation")) {
|
|
@@ -225,15 +232,37 @@ export function classifyRecoveryFact(input) {
|
|
|
225
232
|
if (code.includes("verif") || state.includes("verif")) {
|
|
226
233
|
return "verification-failed";
|
|
227
234
|
}
|
|
235
|
+
// Controller interrupt / orphaned mid-run: reconcile first (not blind rerun).
|
|
236
|
+
if (failure === "controller-interrupted" ||
|
|
237
|
+
code.includes("controller-interrupted") ||
|
|
238
|
+
code.includes("interrupted") ||
|
|
239
|
+
dag === "interrupted" ||
|
|
240
|
+
dag === "orphaned" ||
|
|
241
|
+
state.includes("controller-interrupted") ||
|
|
242
|
+
state.includes("interrupted")) {
|
|
243
|
+
return "needs-reconcile";
|
|
244
|
+
}
|
|
245
|
+
// Live stall / quiet while runner lease may still be fresh — observe/doctor only
|
|
246
|
+
// (Console executable CTAs: report + doctor; reconcile stays CLI-hint).
|
|
247
|
+
if (dag === "running-suspected-stall" ||
|
|
248
|
+
dag === "running-quiet" ||
|
|
249
|
+
dag === "suspected-stall" ||
|
|
250
|
+
dag.includes("suspected-stall") ||
|
|
251
|
+
state.includes("suspected-stall")) {
|
|
252
|
+
return "orphan-unknown";
|
|
253
|
+
}
|
|
228
254
|
if (state === "succeeded" ||
|
|
229
255
|
state === "completed" ||
|
|
256
|
+
state === "finished" ||
|
|
230
257
|
dag === "succeeded" ||
|
|
231
|
-
dag === "completed"
|
|
258
|
+
dag === "completed" ||
|
|
259
|
+
dag === "finished") {
|
|
232
260
|
return "terminal-succeeded";
|
|
233
261
|
}
|
|
234
262
|
if (state === "failed" ||
|
|
235
263
|
state.includes("fail") ||
|
|
236
264
|
dag === "failed" ||
|
|
265
|
+
dag === "partial_failed" ||
|
|
237
266
|
code.includes("fail")) {
|
|
238
267
|
return "terminal-failed";
|
|
239
268
|
}
|
|
@@ -4,6 +4,7 @@ import { createServer } from "node:http";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { LoopAgentClient } from "../loop-agent/loop-agent-client.js";
|
|
7
|
+
import { DAG_ABSOLUTE_MAX_WALL_CLOCK_MS } from "../../shared/timeout-policy.js";
|
|
7
8
|
import { openConsoleAppData } from "./app-data.js";
|
|
8
9
|
import { DagConfirmationStore } from "./dag-confirmation.js";
|
|
9
10
|
import { DagExecutionReceiptStore } from "./dag-execution-receipt.js";
|
|
@@ -24,6 +25,30 @@ import { resolveSiblingLoopAgentBin } from "./sibling-controller.js";
|
|
|
24
25
|
import { NightAuxTicker } from "./night-aux-ticker.js";
|
|
25
26
|
import { deriveObserveRouteCapabilities } from "../observe/health.js";
|
|
26
27
|
import { createObserveRouteContext, defaultObserveStaticDir, handleMatchedObserveRoute, matchObserveGetRoute, ROUTES, serveObserveStatic, } from "../observe/routes.js";
|
|
28
|
+
/**
|
|
29
|
+
* Default hard timeout for the Console-owned LoopAgentClient.
|
|
30
|
+
*
|
|
31
|
+
* Console task-nav operations (`dag execute`, `task advance --approve-gate`,
|
|
32
|
+
* etc.) spawn a long-lived sibling runner via the operation runner. The
|
|
33
|
+
* worker client hard-kills children after `defaultTimeoutMs` (default 120s),
|
|
34
|
+
* which is far below multi-node DAG wall clocks and leaves `state.json`
|
|
35
|
+
* stuck in `running` with no error. Align with shared `DAG_ABSOLUTE_MAX_WALL_CLOCK_MS`
|
|
36
|
+
* (4h) so the client is a safety net, not a false deadline.
|
|
37
|
+
*/
|
|
38
|
+
export const DEFAULT_CONSOLE_CLIENT_TIMEOUT_MS = DAG_ABSOLUTE_MAX_WALL_CLOCK_MS;
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the Console LoopAgentClient default timeout.
|
|
41
|
+
* `0` disables the client hard timeout (DAG kernel owns wall clock).
|
|
42
|
+
*/
|
|
43
|
+
export function resolveConsoleDefaultTimeoutMs(override) {
|
|
44
|
+
if (override === undefined) {
|
|
45
|
+
return DEFAULT_CONSOLE_CLIENT_TIMEOUT_MS;
|
|
46
|
+
}
|
|
47
|
+
if (!Number.isFinite(override) || override < 0) {
|
|
48
|
+
throw new Error(`console serve: invalid defaultTimeoutMs ${String(override)} (expected >= 0)`);
|
|
49
|
+
}
|
|
50
|
+
return override;
|
|
51
|
+
}
|
|
27
52
|
/** Built static assets live next to the compiled server under dist/worker/console/static. */
|
|
28
53
|
export function defaultConsoleStaticDir(fromFileUrl = import.meta.url) {
|
|
29
54
|
return path.join(path.dirname(fileURLToPath(fromFileUrl)), "static");
|
|
@@ -74,6 +99,9 @@ export async function createConsoleServer(options) {
|
|
|
74
99
|
loopAgentBin: resolveSiblingLoopAgentBin(),
|
|
75
100
|
artifactRoot,
|
|
76
101
|
resolveIdentity: true,
|
|
102
|
+
// See DEFAULT_CONSOLE_CLIENT_TIMEOUT_MS: dag execute / task advance
|
|
103
|
+
// must not inherit the 120s worker-command default.
|
|
104
|
+
defaultTimeoutMs: resolveConsoleDefaultTimeoutMs(options.defaultTimeoutMs),
|
|
77
105
|
});
|
|
78
106
|
if (!options.skipReconcile) {
|
|
79
107
|
await operations.reconcileOnBoot();
|