@tea-agent/loop-agent 0.34.1 → 0.34.3

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 CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.34.3] - 2026-08-12
6
+
7
+ ### 重点更新
8
+
9
+ - 修复 PRD 导入时 grill-me 模板占位符路径污染写入集的问题,确保仅处理真实需求路径
10
+
11
+ ### 改进
12
+
13
+ - 语义化导入现可作为兜底方案推荐工程边界,提升需求解析的准确性
14
+ - 将语义约束与验证信息合并至控制台草稿,避免表单默认值覆盖精确的验证命令
15
+
16
+ ### 修复
17
+
18
+ - 修复 grill-me 模板占位符路径未被过滤即进入写入集,导致占位内容污染实际工程数据的问题
19
+
20
+ ## [0.34.2] - 2026-08-12
21
+
22
+ ### 重点更新
23
+
24
+ - 修复控制台 DAG 执行在 120 秒后被强制终止导致状态卡死的问题,默认超时现提升至 4 小时
25
+
26
+ ### 改进
27
+
28
+ - DAG 挂钟时间常量统一收敛至 src/shared 共享模块管理,移除了 workflows 中不必要的内核导入
29
+
30
+ ### 修复
31
+
32
+ - 修复控制台任务导航执行 DAG 时因 LoopAgentClient 默认 120 秒超时被强制终止的问题,现已将默认超时提升至 4 小时,并支持通过 --default-timeout-ms 参数覆盖
33
+ - 修正自定义模型合并报告中的文档描述错误
34
+
5
35
  ## [0.34.1] - 2026-08-11
6
36
 
7
37
  ### 重点更新
@@ -4,6 +4,7 @@ import { createTask, getTaskPaths, loadTaskConfig, } from "../../task/runtime.js
4
4
  import { importPrdDocument } from "../../task/source-references.js";
5
5
  import { prepareTaskSource } from "../../task/source-prepare/index.js";
6
6
  import { isIntakeSoftGapCode, resolvePrdImportRole, } from "../../task/source-prepare/artifact-meta.js";
7
+ import { sanitizeDraftPlaceholders } from "../../task/source-prepare/placeholder-paths.js";
7
8
  import { evaluateSemanticIntakeEligibility, isSemanticIntakeDisabled, readSemanticIntakeAttempted, runSemanticIntake, } from "../../task/source-prepare/semantic-intake.js";
8
9
  import { validateImportedPrdReferences } from "../../task/source-prepare/reference-integrity.js";
9
10
  import { logicalRevisionForState, observeTaskContractState, recoverTaskContract, } from "../../task/contract/index.js";
@@ -587,9 +588,12 @@ export async function advanceTaskLifecycle(input) {
587
588
  if (input.fromDraftPath) {
588
589
  try {
589
590
  const draftRaw = await readFile(path.resolve(input.fromDraftPath), "utf-8");
591
+ const parsedDraft = JSON.parse(draftRaw);
592
+ // Strip Console grill-me placeholder strings so they never become
593
+ // writeSet globs / projected 需求.md bullets.
590
594
  draftIntent = {
591
595
  kind: "draft",
592
- draft: JSON.parse(draftRaw),
596
+ draft: sanitizeDraftPlaceholders(parsedDraft),
593
597
  sourcePath: input.fromDraftPath,
594
598
  };
595
599
  }
@@ -1,5 +1,10 @@
1
1
  import { DEFAULT_TIMEOUT_MS, MAX_ALLOWED_TIMEOUT_MS } from "../executors/pi-executor.js";
2
2
  export { MAX_ALLOWED_TIMEOUT_MS };
3
+ /**
4
+ * DAG runner 绝对最大墙钟(4h)。不可被空心跳续期;
5
+ * 是 DEFAULT_LIVENESS_POLICY 与 Console LoopAgentClient 默认超时的单一真源。
6
+ */
7
+ export const DAG_ABSOLUTE_MAX_WALL_CLOCK_MS = 14_400_000;
3
8
  /**
4
9
  * Clamp timeout values for Pi steps. Undefined or non-positive values fall back to
5
10
  * DEFAULT_TIMEOUT_MS so task-level invalid values do not disable Pi execution timeouts.
@@ -2,6 +2,7 @@ export * from "./types.js";
2
2
  export * from "./path-policy.js";
3
3
  export * from "./parse-intent.js";
4
4
  export * from "./artifact-meta.js";
5
+ export * from "./placeholder-paths.js";
5
6
  export * from "./semantic-intake.js";
6
7
  export * from "./reference-integrity.js";
7
8
  export * from "./completeness.js";
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Shared filters for Console / grill-me placeholder text that must never
3
+ * become writeSet paths or contract scope bullets.
4
+ *
5
+ * Placeholders are Chinese cue-words + fullwidth brackets from interview
6
+ * recommendations (e.g. "(工程边界须人类确认;示例:src/**, test/**)").
7
+ * Do not require glob syntax — legitimate paths like `package.json` must pass.
8
+ */
9
+ export const PLACEHOLDER_PATH_RE = /请|示例|模板|(|)|须人类确认|勿套用|暂无|填写|确认|按.*结构/;
10
+ export function isNonPlaceholderPath(value) {
11
+ return (typeof value === "string" &&
12
+ value.trim().length > 0 &&
13
+ !PLACEHOLDER_PATH_RE.test(value));
14
+ }
15
+ export function filterPlaceholderPaths(values) {
16
+ if (!Array.isArray(values))
17
+ return [];
18
+ return values
19
+ .map((item) => (typeof item === "string" ? item.trim() : ""))
20
+ .filter(isNonPlaceholderPath);
21
+ }
22
+ function isPlaceholderText(value) {
23
+ return typeof value === "string" && PLACEHOLDER_PATH_RE.test(value);
24
+ }
25
+ /**
26
+ * Strip grill-me / form placeholder strings from a draft before prepare.
27
+ * Keeps real defaults like `npm run typecheck` and `.harness/**`.
28
+ */
29
+ export function sanitizeDraftPlaceholders(draft) {
30
+ if (!draft || typeof draft !== "object")
31
+ return draft;
32
+ const root = draft;
33
+ const next = { ...root };
34
+ if (root.constraints && typeof root.constraints === "object") {
35
+ const constraints = root.constraints;
36
+ next.constraints = {
37
+ ...constraints,
38
+ allowedPaths: filterPlaceholderPaths(constraints.allowedPaths),
39
+ forbiddenPaths: filterPlaceholderPaths(constraints.forbiddenPaths),
40
+ };
41
+ }
42
+ if (root.requirement && typeof root.requirement === "object") {
43
+ const requirement = root.requirement;
44
+ let scope = requirement.scope;
45
+ if (Array.isArray(requirement.scope)) {
46
+ scope = requirement.scope
47
+ .map((item) => (typeof item === "string" ? item.trim() : ""))
48
+ .filter((item) => item.length > 0 && !isPlaceholderText(item));
49
+ }
50
+ let nonGoals = requirement.nonGoals;
51
+ if (Array.isArray(requirement.nonGoals)) {
52
+ nonGoals = requirement.nonGoals
53
+ .map((item) => (typeof item === "string" ? item.trim() : ""))
54
+ .filter((item) => item.length > 0 && !isPlaceholderText(item));
55
+ }
56
+ let acceptanceCriteria = requirement.acceptanceCriteria;
57
+ if (Array.isArray(requirement.acceptanceCriteria)) {
58
+ acceptanceCriteria = requirement.acceptanceCriteria.filter((item) => {
59
+ if (typeof item === "string") {
60
+ return item.trim().length > 0 && !isPlaceholderText(item);
61
+ }
62
+ if (item && typeof item === "object") {
63
+ const text = item.text;
64
+ return typeof text === "string" && !isPlaceholderText(text);
65
+ }
66
+ return true;
67
+ });
68
+ }
69
+ next.requirement = {
70
+ ...requirement,
71
+ scope,
72
+ nonGoals,
73
+ acceptanceCriteria,
74
+ };
75
+ }
76
+ return next;
77
+ }
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * P2 Semantic Intake: one-shot, read-only Pi structuring of imported PRDs
3
3
  * into TaskContractDraftV1. Does not write source/需求.md directly — caller
4
- * re-enters prepare with kind:"draft". Engineering paths/verify never come
5
- * from the model; only from flags / existing task config.
4
+ * re-enters prepare with kind:"draft".
5
+ *
6
+ * Engineering boundary priority: explicit flags > existing task config >
7
+ * optional model recommendation (repo-layout-aware fallback for Console
8
+ * web-wizard flows that never collect human paths).
6
9
  */
7
- import { mkdir, readFile, writeFile } from "node:fs/promises";
10
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
8
11
  import path from "node:path";
9
12
  import { z } from "zod";
10
13
  import { executePiStep } from "../../executors/pi-executor.js";
@@ -14,6 +17,7 @@ import { loadHarnessManifest } from "../../governance/harness.js";
14
17
  import { TASK_CONTRACT_DRAFT_SCHEMA_VERSION } from "../contract/constants.js";
15
18
  import { getTaskPaths } from "../runtime.js";
16
19
  import { detectProductArtifactMeta } from "./artifact-meta.js";
20
+ import { filterPlaceholderPaths } from "./placeholder-paths.js";
17
21
  export const SEMANTIC_INTAKE_ARTIFACT_REL = "artifacts/intake/semantic-draft.json";
18
22
  export const SEMANTIC_INTAKE_ATTEMPT_MARKER = "artifacts/intake/semantic-intake-attempted.json";
19
23
  const productArtifactBlockers = new Set([
@@ -22,6 +26,20 @@ const productArtifactBlockers = new Set([
22
26
  "PRODUCT_REQUIREMENT_PENDING",
23
27
  "PRODUCT_ARTIFACT_NOT_EXECUTABLE",
24
28
  ]);
29
+ const engineeringRecommendationSchema = z
30
+ .object({
31
+ taskKind: z.string().min(1).optional(),
32
+ allowedPaths: z.array(z.string()).optional(),
33
+ forbiddenPaths: z.array(z.string()).optional(),
34
+ verifyCommands: z
35
+ .array(z.object({
36
+ label: z.string().min(1),
37
+ command: z.string().min(1),
38
+ timeoutMs: z.number().int().positive().optional(),
39
+ }))
40
+ .optional(),
41
+ })
42
+ .optional();
25
43
  const requirementDraftSchema = z.object({
26
44
  objective: z.string().min(1),
27
45
  scope: z.array(z.string()).default([]),
@@ -39,6 +57,8 @@ const requirementDraftSchema = z.object({
39
57
  openQuestions: z.array(z.string()).optional(),
40
58
  assumptions: z.array(z.string()).optional(),
41
59
  title: z.string().optional(),
60
+ /** Optional model engineering recommendation (flags/existing still win). */
61
+ engineering: engineeringRecommendationSchema,
42
62
  });
43
63
  export function isSemanticIntakeDisabled(env = process.env) {
44
64
  const raw = (env.LOOP_AGENT_SEMANTIC_INTAKE ?? "").trim().toLowerCase();
@@ -130,26 +150,62 @@ export function assertSemanticDraftAcceptable(draft) {
130
150
  }
131
151
  }
132
152
  export function buildDraftFromSemanticIntake(input) {
133
- const allowedPaths = input.flags.allowedPaths?.length
134
- ? [
135
- ...(input.existingAllowedPaths ?? []),
136
- ...input.flags.allowedPaths,
137
- ]
138
- : (input.existingAllowedPaths ?? input.flags.allowedPaths ?? []);
139
- const forbiddenPaths = input.flags.forbiddenPaths?.length
140
- ? [
141
- ...(input.existingForbiddenPaths ?? []),
142
- ...input.flags.forbiddenPaths,
143
- ]
144
- : (input.existingForbiddenPaths ?? input.flags.forbiddenPaths ?? []);
145
- const verifyCommands = input.flags.verifyCommands?.length
146
- ? input.flags.verifyCommands
147
- : (input.existingVerify ?? []);
153
+ const modelEng = input.semantic.engineering ?? {};
154
+ const modelAllowed = filterPlaceholderPaths(modelEng.allowedPaths);
155
+ const modelForbidden = filterPlaceholderPaths(modelEng.forbiddenPaths);
156
+ const modelVerify = (modelEng.verifyCommands ?? []).filter((c) => c.label?.trim() && c.command?.trim());
157
+ // Priority: explicit flags > existing task config > model recommendation.
158
+ const flagAllowed = input.flags.allowedPaths ?? [];
159
+ const existingAllowed = input.existingAllowedPaths ?? [];
160
+ let allowedPaths;
161
+ if (flagAllowed.length > 0) {
162
+ allowedPaths = [...existingAllowed, ...flagAllowed];
163
+ }
164
+ else if (existingAllowed.length > 0) {
165
+ allowedPaths = existingAllowed;
166
+ }
167
+ else {
168
+ allowedPaths = modelAllowed;
169
+ }
170
+ const flagForbidden = input.flags.forbiddenPaths ?? [];
171
+ const existingForbidden = input.existingForbiddenPaths ?? [];
172
+ let forbiddenPaths;
173
+ if (flagForbidden.length > 0) {
174
+ forbiddenPaths = [...existingForbidden, ...flagForbidden];
175
+ }
176
+ else if (existingForbidden.length > 0) {
177
+ forbiddenPaths = existingForbidden;
178
+ }
179
+ else {
180
+ forbiddenPaths = modelForbidden;
181
+ }
182
+ let verifyCommands;
183
+ if (input.flags.verifyCommands?.length) {
184
+ verifyCommands = input.flags.verifyCommands;
185
+ }
186
+ else if (input.existingVerify?.length) {
187
+ verifyCommands = input.existingVerify;
188
+ }
189
+ else {
190
+ verifyCommands = modelVerify;
191
+ }
192
+ // flags.taskKind is hard; bare caller "standard" is a soft default and must
193
+ // not block a model recommendation for Console web-wizard flows.
194
+ const explicitTaskKind = input.flags.taskKind?.trim();
195
+ const callerTaskKind = input.taskKind?.trim();
196
+ const modelTaskKind = modelEng.taskKind?.trim();
197
+ const taskKind = explicitTaskKind ||
198
+ (callerTaskKind && callerTaskKind !== "standard"
199
+ ? callerTaskKind
200
+ : undefined) ||
201
+ modelTaskKind ||
202
+ callerTaskKind ||
203
+ "standard";
148
204
  const draft = {
149
205
  schemaVersion: TASK_CONTRACT_DRAFT_SCHEMA_VERSION,
150
206
  taskId: input.taskId,
151
207
  title: input.semantic.title?.trim() || input.title,
152
- taskKind: input.flags.taskKind || input.taskKind || "standard",
208
+ taskKind,
153
209
  requirement: {
154
210
  objective: input.semantic.objective.trim(),
155
211
  scope: input.semantic.scope.map((s) => s.trim()).filter(Boolean),
@@ -161,7 +217,9 @@ export function buildDraftFromSemanticIntake(input) {
161
217
  },
162
218
  constraints: {
163
219
  invariants: input.flags.invariants ?? [],
164
- allowedPaths: [...new Set(allowedPaths.map((p) => p.trim()).filter(Boolean))],
220
+ allowedPaths: [
221
+ ...new Set(allowedPaths.map((p) => p.trim()).filter(Boolean)),
222
+ ],
165
223
  forbiddenPaths: [
166
224
  ...new Set(forbiddenPaths.map((p) => p.trim()).filter(Boolean)),
167
225
  ],
@@ -192,7 +250,96 @@ export function buildDraftFromSemanticIntake(input) {
192
250
  }
193
251
  return draft;
194
252
  }
195
- function buildPrompt(input) {
253
+ /**
254
+ * Cheap repo-layout hint injected into the semantic intake prompt so the model
255
+ * can recommend minimal write boundaries and real verify commands when humans
256
+ * never fill Console form engineering fields.
257
+ */
258
+ export async function buildRepoHint(repoRoot) {
259
+ const lines = ["Repo layout hint (best-effort, may be incomplete):"];
260
+ try {
261
+ const top = await readdir(repoRoot, { withFileTypes: true });
262
+ const dirs = top
263
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
264
+ .map((entry) => entry.name)
265
+ .slice(0, 24);
266
+ const files = top
267
+ .filter((entry) => entry.isFile())
268
+ .map((entry) => entry.name)
269
+ .slice(0, 24);
270
+ lines.push(`top-level dirs: ${dirs.join(", ") || "(none)"}`);
271
+ lines.push(`top-level files: ${files.join(", ") || "(none)"}`);
272
+ const packageCandidates = [
273
+ "package.json",
274
+ path.join("source", "package.json"),
275
+ path.join("packages", "package.json"),
276
+ ];
277
+ for (const rel of packageCandidates) {
278
+ const abs = path.join(repoRoot, rel);
279
+ try {
280
+ const raw = await readFile(abs, "utf-8");
281
+ const pkg = JSON.parse(raw);
282
+ const scripts = Object.keys(pkg.scripts ?? {}).slice(0, 20);
283
+ lines.push(`${rel} scripts: ${scripts.join(", ") || "(none)"}`);
284
+ if (pkg.workspaces !== undefined) {
285
+ lines.push(`${rel} workspaces: ${JSON.stringify(pkg.workspaces).slice(0, 200)}`);
286
+ }
287
+ }
288
+ catch {
289
+ // optional
290
+ }
291
+ }
292
+ // Sample test-like files under common roots (bounded).
293
+ const testRoots = ["src", "source", "packages", "test", "tests"].filter((name) => dirs.includes(name));
294
+ const samples = [];
295
+ const counts = new Map();
296
+ const walk = async (dir, depth) => {
297
+ if (depth > 3 || samples.length >= 15)
298
+ return;
299
+ let entries;
300
+ try {
301
+ entries = await readdir(dir, { withFileTypes: true });
302
+ }
303
+ catch {
304
+ return;
305
+ }
306
+ for (const entry of entries) {
307
+ if (entry.name.startsWith(".") || entry.name === "node_modules")
308
+ continue;
309
+ const abs = path.join(dir, entry.name);
310
+ if (entry.isDirectory()) {
311
+ await walk(abs, depth + 1);
312
+ continue;
313
+ }
314
+ if (!entry.isFile())
315
+ continue;
316
+ if (!/\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs)$/i.test(entry.name))
317
+ continue;
318
+ const rel = path.relative(repoRoot, abs).replace(/\\/g, "/");
319
+ const top = rel.split("/")[0] ?? rel;
320
+ counts.set(top, (counts.get(top) ?? 0) + 1);
321
+ if (samples.length < 15)
322
+ samples.push(rel);
323
+ }
324
+ };
325
+ for (const root of testRoots) {
326
+ await walk(path.join(repoRoot, root), 0);
327
+ }
328
+ const total = [...counts.values()].reduce((sum, n) => sum + n, 0);
329
+ if (total > 0) {
330
+ const byTop = [...counts.entries()]
331
+ .map(([name, n]) => `${name}:${n}`)
332
+ .join(", ");
333
+ lines.push(`test-like files: total=${total} byTop=${byTop}`);
334
+ lines.push(`test samples: ${samples.join(", ")}`);
335
+ }
336
+ }
337
+ catch {
338
+ lines.push("(repo scan failed)");
339
+ }
340
+ return lines.join("\n");
341
+ }
342
+ async function buildPrompt(input) {
196
343
  const bodies = input.documents
197
344
  .map((doc, index) => {
198
345
  const meta = detectProductArtifactMeta(doc.content);
@@ -213,11 +360,18 @@ function buildPrompt(input) {
213
360
  ].join("\n");
214
361
  })
215
362
  .join("\n\n");
363
+ const repoHint = input.repoRoot
364
+ ? await buildRepoHint(input.repoRoot)
365
+ : "(no repo layout hint)";
216
366
  return [
217
367
  "You are a read-only requirement structurer for loop-agent task intake.",
218
368
  "Convert imported product documents into a thin requirement draft JSON.",
219
369
  "Do NOT invent product behavior that is not supported by the documents.",
220
- "Do NOT emit engineering fields (allowedPaths, forbiddenPaths, verifyCommands, taskKind).",
370
+ "You MAY recommend an optional engineering block when the repo layout hint supports it:",
371
+ "- allowedPaths / forbiddenPaths as minimal precise globs (e.g. source/packages/**/*.test.ts)",
372
+ "- verifyCommands as real package scripts (e.g. pnpm -C source test)",
373
+ "- taskKind when clearly implied (backend-test / frontend-test / standard / …)",
374
+ "Prefer smallest correct write boundary; do not invent product paths that are not in the layout hint.",
221
375
  "Do NOT rewrite or suggest editing the original PRD files.",
222
376
  "Prefer explicit text; use provenance derived for rephrasing; use inferred only when clearly implied — if you must invent ACs, leave acceptanceCriteria empty so the system can stop for clarification.",
223
377
  "Return exactly one JSON object, no markdown prose outside optional fence:",
@@ -235,9 +389,18 @@ function buildPrompt(input) {
235
389
  ],
236
390
  openQuestions: ["optional"],
237
391
  assumptions: ["optional"],
392
+ engineering: {
393
+ taskKind: "optional string",
394
+ allowedPaths: ["optional globs"],
395
+ forbiddenPaths: ["optional globs"],
396
+ verifyCommands: [
397
+ { label: "test", command: "pnpm -C source test" },
398
+ ],
399
+ },
238
400
  }, null, 2),
239
401
  `taskId: ${input.taskId}`,
240
402
  `fallbackTitle: ${input.title}`,
403
+ repoHint,
241
404
  "Documents:",
242
405
  bodies,
243
406
  ].join("\n\n");
@@ -314,14 +477,16 @@ export async function runSemanticIntake(input) {
314
477
  const attached = usableDocs
315
478
  .map((d) => d.absolutePath)
316
479
  .filter((p) => Boolean(p));
480
+ const prompt = await buildPrompt({
481
+ taskId: input.taskId,
482
+ title: input.title,
483
+ documents: usableDocs,
484
+ repoRoot: input.repoRoot,
485
+ });
317
486
  const result = await execute({
318
487
  attachedFiles: attached,
319
488
  modelConfig: resolveDagPiModelConfig(model),
320
- prompt: buildPrompt({
321
- taskId: input.taskId,
322
- title: input.title,
323
- documents: usableDocs,
324
- }),
489
+ prompt,
325
490
  repoRoot: input.repoRoot,
326
491
  step: "analyze",
327
492
  toolNames: ["read", "grep", "find", "ls"],
@@ -720,6 +720,7 @@ export function buildAgentWorkerProgram() {
720
720
  .option("--host <host>", "Bind host (default 127.0.0.1; use 0.0.0.0 for LAN, no network auth)", "127.0.0.1")
721
721
  .option("--debug", "Log request source/method/path/status to stderr")
722
722
  .option("--app-data <path>", "Override Console application-data root (Draft/Operation/confirmation)")
723
+ .option("--default-timeout-ms <ms>", "LoopAgentClient hard timeout for Console operations (ms; default 14400000 / 4h; 0 disables)")
723
724
  .action(async (options) => {
724
725
  await runConsoleServe({
725
726
  repoRoot: path.resolve(options.repo),
@@ -729,6 +730,7 @@ export function buildAgentWorkerProgram() {
729
730
  appDataRoot: options.appData
730
731
  ? path.resolve(options.appData)
731
732
  : undefined,
733
+ defaultTimeoutMsRaw: options.defaultTimeoutMs,
732
734
  });
733
735
  });
734
736
  consoleCmd
@@ -738,6 +740,7 @@ export function buildAgentWorkerProgram() {
738
740
  .option("--host <host>", "Bind host (default 127.0.0.1; use 0.0.0.0 for LAN, no network auth)", "127.0.0.1")
739
741
  .option("--debug", "Log request source/method/path/status to stderr")
740
742
  .option("--app-data <path>", "Override Console application-data root (Draft/Operation/confirmation)")
743
+ .option("--default-timeout-ms <ms>", "LoopAgentClient hard timeout for Console operations (ms; default 14400000 / 4h; 0 disables)")
741
744
  .description("Start Loop Operator Console HTTP server (compat entry; equivalent to bare `agent-worker console`)")
742
745
  .action(async (options) => {
743
746
  await runConsoleServe({
@@ -748,6 +751,7 @@ export function buildAgentWorkerProgram() {
748
751
  appDataRoot: options.appData
749
752
  ? path.resolve(options.appData)
750
753
  : undefined,
754
+ defaultTimeoutMsRaw: options.defaultTimeoutMs,
751
755
  });
752
756
  });
753
757
  consoleCmd
@@ -835,6 +839,17 @@ export async function main(argv = process.argv) {
835
839
  async function readTaskYaml(taskSpecPath) {
836
840
  return YAML.parse(await readFile(taskSpecPath, "utf-8"));
837
841
  }
842
+ /** Parse optional `--default-timeout-ms`; undefined keeps Console default. */
843
+ function parseOptionalTimeoutMs(raw) {
844
+ if (raw === undefined || raw.trim() === "") {
845
+ return undefined;
846
+ }
847
+ const value = Number(raw);
848
+ if (!Number.isFinite(value) || value < 0 || !Number.isInteger(value)) {
849
+ throw new Error(`--default-timeout-ms must be a non-negative integer (ms); got ${JSON.stringify(raw)}`);
850
+ }
851
+ return value;
852
+ }
838
853
  /** Shared console serve entry used by both bare `console` and `console serve`. */
839
854
  async function runConsoleServe(options) {
840
855
  const { createConsoleServer } = await import("./console/server.js");
@@ -845,6 +860,7 @@ async function runConsoleServe(options) {
845
860
  port: options.port,
846
861
  debug: options.debug === true,
847
862
  appDataRoot: options.appDataRoot,
863
+ defaultTimeoutMs: parseOptionalTimeoutMs(options.defaultTimeoutMsRaw),
848
864
  });
849
865
  process.stdout.write(`${server.url}\n`);
850
866
  await new Promise((resolve) => {
@@ -8,6 +8,7 @@ import path from "node:path";
8
8
  import { getTaskPaths } from "../../task/runtime.js";
9
9
  import { SEMANTIC_INTAKE_ARTIFACT_REL, } from "../../task/source-prepare/semantic-intake.js";
10
10
  import { extractRequirementFactsFromMarkdown } from "../../task/source-prepare/parse-intent.js";
11
+ import { filterPlaceholderPaths, isNonPlaceholderPath, } from "../../task/source-prepare/placeholder-paths.js";
11
12
  import { emptyDraft } from "./interview/grill-me.js";
12
13
  import { normalizeConsoleWorkflowKind } from "./workflow-kinds.js";
13
14
  function asStringList(value) {
@@ -105,13 +106,13 @@ export function parseEngineeringBoundaryFromParams(p) {
105
106
  if (Array.isArray(value)) {
106
107
  return value
107
108
  .map((item) => (typeof item === "string" ? item.trim() : ""))
108
- .filter(Boolean);
109
+ .filter(isNonPlaceholderPath);
109
110
  }
110
111
  if (typeof value === "string") {
111
112
  return value
112
113
  .split(/[\n,;,;]+/)
113
114
  .map((s) => s.trim())
114
- .filter(Boolean);
115
+ .filter(isNonPlaceholderPath);
115
116
  }
116
117
  return [];
117
118
  };
@@ -176,10 +177,10 @@ export function parseEngineeringBoundaryFromParams(p) {
176
177
  };
177
178
  }
178
179
  export function appendEngineeringCliArgs(args, boundary) {
179
- for (const pathGlob of boundary.allowedPaths ?? []) {
180
+ for (const pathGlob of filterPlaceholderPaths(boundary.allowedPaths)) {
180
181
  args.push("--allowed-path", pathGlob);
181
182
  }
182
- for (const pathGlob of boundary.forbiddenPaths ?? []) {
183
+ for (const pathGlob of filterPlaceholderPaths(boundary.forbiddenPaths)) {
183
184
  args.push("--forbidden-path", pathGlob);
184
185
  }
185
186
  for (const cmd of boundary.verifyCommands ?? []) {
@@ -187,8 +188,10 @@ export function appendEngineeringCliArgs(args, boundary) {
187
188
  }
188
189
  }
189
190
  export function applyEngineeringBoundaryToDraft(draft, boundary) {
190
- if (!(boundary.allowedPaths?.length) &&
191
- !(boundary.forbiddenPaths?.length) &&
191
+ const allowedPaths = filterPlaceholderPaths(boundary.allowedPaths);
192
+ const forbiddenPaths = filterPlaceholderPaths(boundary.forbiddenPaths);
193
+ if (allowedPaths.length === 0 &&
194
+ forbiddenPaths.length === 0 &&
192
195
  !(boundary.verifyCommands?.length)) {
193
196
  return draft;
194
197
  }
@@ -196,11 +199,11 @@ export function applyEngineeringBoundaryToDraft(draft, boundary) {
196
199
  ...draft,
197
200
  constraints: {
198
201
  invariants: draft.constraints?.invariants ?? [],
199
- allowedPaths: boundary.allowedPaths?.length
200
- ? boundary.allowedPaths
202
+ allowedPaths: allowedPaths.length > 0
203
+ ? allowedPaths
201
204
  : (draft.constraints?.allowedPaths ?? []),
202
- forbiddenPaths: boundary.forbiddenPaths?.length
203
- ? boundary.forbiddenPaths
205
+ forbiddenPaths: forbiddenPaths.length > 0
206
+ ? forbiddenPaths
204
207
  : (draft.constraints?.forbiddenPaths ?? []),
205
208
  },
206
209
  verification: {
@@ -253,15 +256,53 @@ export async function buildDraftFromTaskIntake(input) {
253
256
  const title = artifact?.draft?.title?.trim() ||
254
257
  artifact?.semantic?.title?.trim() ||
255
258
  input.title;
259
+ // Preserve semantic engineering boundary (paths/verify/taskKind) so the
260
+ // Console draft does not fall back to interview defaults and overwrite
261
+ // TX1 precise values on contractApply --from-draft.
262
+ const semDraftConstraints = artifact?.draft?.constraints;
263
+ const semDraftVerification = artifact?.draft?.verification;
264
+ const semTaskKind = artifact?.draft?.taskKind?.trim();
265
+ const mergedAllowed = filterPlaceholderPaths(semDraftConstraints?.allowedPaths);
266
+ const mergedForbidden = filterPlaceholderPaths(semDraftConstraints?.forbiddenPaths);
267
+ const mergedVerify = Array.isArray(semDraftVerification?.commands)
268
+ ? semDraftVerification.commands
269
+ .filter((cmd) => typeof cmd?.command === "string" && cmd.command.trim().length > 0)
270
+ .map((cmd) => ({
271
+ label: typeof cmd.label === "string" && cmd.label.trim()
272
+ ? cmd.label.trim()
273
+ : "verify",
274
+ command: cmd.command.trim(),
275
+ ...(typeof cmd.timeoutMs === "number"
276
+ ? { timeoutMs: cmd.timeoutMs }
277
+ : {}),
278
+ }))
279
+ : [];
256
280
  draft = {
257
281
  ...draft,
258
282
  title,
283
+ ...(semTaskKind
284
+ ? { taskKind: normalizeConsoleWorkflowKind(semTaskKind, taskKind) }
285
+ : {}),
259
286
  requirement: {
260
287
  objective: semanticReq.objective.trim(),
261
288
  scope: asStringList(semanticReq.scope),
262
289
  nonGoals: asStringList(semanticReq.nonGoals),
263
290
  acceptanceCriteria: asAcceptance(semanticReq.acceptanceCriteria),
264
291
  },
292
+ ...(mergedAllowed.length > 0 ||
293
+ mergedForbidden.length > 0 ||
294
+ Array.isArray(semDraftConstraints?.invariants)
295
+ ? {
296
+ constraints: {
297
+ invariants: asStringList(semDraftConstraints?.invariants),
298
+ allowedPaths: mergedAllowed,
299
+ forbiddenPaths: mergedForbidden,
300
+ },
301
+ }
302
+ : {}),
303
+ ...(mergedVerify.length > 0
304
+ ? { verification: { commands: mergedVerify } }
305
+ : {}),
265
306
  openQuestions: asStringList(artifact?.draft?.openQuestions ?? artifact?.semantic?.openQuestions) || undefined,
266
307
  assumptions: asStringList(artifact?.draft?.assumptions ?? artifact?.semantic?.assumptions) || undefined,
267
308
  references: artifact?.draft?.references,
@@ -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();
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { DAG_ABSOLUTE_MAX_WALL_CLOCK_MS } from "../../shared/timeout-policy.js";
2
3
  /**
3
4
  * Adaptive liveness policy for DAG/Pi supervision.
4
5
  *
@@ -15,7 +16,7 @@ export const DEFAULT_LIVENESS_POLICY = {
15
16
  stallProbeMs: 900_000,
16
17
  abortGraceMs: 30_000,
17
18
  /** 4h absolute max wall clock — cannot be renewed by empty heartbeats. */
18
- absoluteMaxWallClockMs: 14_400_000,
19
+ absoluteMaxWallClockMs: DAG_ABSOLUTE_MAX_WALL_CLOCK_MS,
19
20
  };
20
21
  export const dagLivenessPolicySchema = z
21
22
  .object({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.34.1",
3
+ "version": "0.34.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",