@tea-agent/loop-agent 0.28.8 → 0.28.9
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 +15 -0
- package/dist/application/task-lifecycle/advance.js +24 -1
- package/dist/application/task-lifecycle/plan-transitions.js +7 -4
- package/dist/cli/program.js +2 -2
- package/dist/commands/init.js +232 -65
- package/dist/executors/dag-pi-executor.js +25 -0
- package/dist/executors/model-routing.js +0 -58
- package/dist/executors/pi-sdk-executor.js +14 -1
- package/dist/executors/shell-executor.js +123 -36
- package/dist/governance/manifest-types.js +18 -51
- package/dist/task/source-prepare/build-draft.js +10 -9
- package/dist/task/source-prepare/prepare.js +107 -4
- package/dist/workflows/dag/init-hybrid.js +184 -46
- package/dist/workflows/dag/node-execution.js +176 -21
- package/dist/workflows/dag/types.js +58 -0
- package/docs/governance/README.md +1 -1
- package/docs/templates/harness.schema.json +4 -8
- package/package.json +1 -1
- package/skills/loop-agent/references/command-reference.md +5 -2
- package/skills/loop-agent/references/model-routing.md +14 -7
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { pathMatchesPattern } from "../../shared/git-progress.js";
|
|
5
6
|
import { recordDecisionEnvelopeForNode, shouldPauseOnHumanEscalation, writeHumanEscalationArtifacts, } from "./decision-envelope.js";
|
|
6
7
|
import { writeNodeRecord, writeNodeSkillArtifacts } from "./run-store.js";
|
|
7
8
|
import { resolveContextPolicy } from "./context-policy.js";
|
|
@@ -18,6 +19,100 @@ import { parseRepairArtifactFromText, resolveRepairTaskForGate, validateRepairAr
|
|
|
18
19
|
import { resolveModelForTask, } from "./types.js";
|
|
19
20
|
import { loadHarnessManifest } from "../../governance/harness.js";
|
|
20
21
|
import { resolveExecutorThinkingMatrix } from "../../executors/model-routing.js";
|
|
22
|
+
function canonicalApprovalJson(value) {
|
|
23
|
+
if (Array.isArray(value)) {
|
|
24
|
+
return `[${value.map((entry) => canonicalApprovalJson(entry)).join(",")}]`;
|
|
25
|
+
}
|
|
26
|
+
if (value && typeof value === "object") {
|
|
27
|
+
const record = value;
|
|
28
|
+
return `{${Object.keys(record)
|
|
29
|
+
.sort()
|
|
30
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalApprovalJson(record[key])}`)
|
|
31
|
+
.join(",")}}`;
|
|
32
|
+
}
|
|
33
|
+
return JSON.stringify(value) ?? "null";
|
|
34
|
+
}
|
|
35
|
+
export function parseAndValidateFinalWriteSetApproval(input) {
|
|
36
|
+
const binding = input.task.finalWriteSetApproval;
|
|
37
|
+
if (!binding) {
|
|
38
|
+
return { ok: false, reason: "missing final write-set approval binding", approvalSourceNodeId: "(missing)" };
|
|
39
|
+
}
|
|
40
|
+
const source = input.state.nodes[binding.approvalSourceNodeId];
|
|
41
|
+
if (source?.status !== "FINISHED" || parseProcessVerdict(source) !== "pass") {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
reason: "final write-set approval source did not finish with VERDICT: pass",
|
|
45
|
+
approvalSourceNodeId: binding.approvalSourceNodeId,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const raw = canonicalNodeOutput(source);
|
|
49
|
+
const blocks = [...raw.matchAll(/```FINAL_WRITE_SET_APPROVAL_JSON\s*\r?\n([\s\S]*?)\r?\n```/g)];
|
|
50
|
+
if (blocks.length !== 1) {
|
|
51
|
+
return { ok: false, reason: `expected exactly one FINAL_WRITE_SET_APPROVAL_JSON block, found ${blocks.length}`, approvalSourceNodeId: binding.approvalSourceNodeId };
|
|
52
|
+
}
|
|
53
|
+
let approval;
|
|
54
|
+
try {
|
|
55
|
+
approval = JSON.parse(blocks[0][1]);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return { ok: false, reason: "final write-set approval is not valid JSON", approvalSourceNodeId: binding.approvalSourceNodeId };
|
|
59
|
+
}
|
|
60
|
+
const required = ["schemaVersion", "writerNodeId", "approvalSourceNodeId", "auditedPlanNodeId", "approvedWriteSet", "taskContractSha256", "auditedPlanSha256", "approvalDigest"];
|
|
61
|
+
if (Object.keys(approval).length !== required.length || required.some((key) => !(key in approval))) {
|
|
62
|
+
return { ok: false, reason: "final write-set approval has an invalid schema", approvalSourceNodeId: binding.approvalSourceNodeId };
|
|
63
|
+
}
|
|
64
|
+
const approved = approval.approvedWriteSet;
|
|
65
|
+
if (approval.schemaVersion !== 1 ||
|
|
66
|
+
approval.writerNodeId !== binding.writerNodeId ||
|
|
67
|
+
approval.writerNodeId !== input.task.id ||
|
|
68
|
+
approval.approvalSourceNodeId !== binding.approvalSourceNodeId ||
|
|
69
|
+
approval.auditedPlanNodeId !== binding.auditedPlanNodeId ||
|
|
70
|
+
!Array.isArray(approved) ||
|
|
71
|
+
!approved.every((entry) => typeof entry === "string") ||
|
|
72
|
+
typeof approval.taskContractSha256 !== "string" ||
|
|
73
|
+
typeof approval.auditedPlanSha256 !== "string" ||
|
|
74
|
+
typeof approval.approvalDigest !== "string") {
|
|
75
|
+
return { ok: false, reason: "final write-set approval binding or field types are invalid", approvalSourceNodeId: binding.approvalSourceNodeId };
|
|
76
|
+
}
|
|
77
|
+
if (!/^[a-f0-9]{64}$/.test(approval.taskContractSha256) ||
|
|
78
|
+
!/^[a-f0-9]{64}$/.test(approval.auditedPlanSha256) ||
|
|
79
|
+
!/^[a-f0-9]{64}$/.test(approval.approvalDigest)) {
|
|
80
|
+
return { ok: false, reason: "final write-set approval digest fields are invalid", approvalSourceNodeId: binding.approvalSourceNodeId };
|
|
81
|
+
}
|
|
82
|
+
const canonicalPayload = { ...approval };
|
|
83
|
+
delete canonicalPayload.approvalDigest;
|
|
84
|
+
const expectedDigest = createHash("sha256")
|
|
85
|
+
.update(canonicalApprovalJson(canonicalPayload))
|
|
86
|
+
.digest("hex");
|
|
87
|
+
if (approval.approvalDigest !== expectedDigest) {
|
|
88
|
+
return { ok: false, reason: "final write-set approval digest mismatch", approvalSourceNodeId: binding.approvalSourceNodeId };
|
|
89
|
+
}
|
|
90
|
+
const expectedTaskDigest = input.spec.taskContractBinding?.canonicalHash;
|
|
91
|
+
const plan = input.state.nodes[binding.auditedPlanNodeId];
|
|
92
|
+
const expectedPlanDigest = createHash("sha256")
|
|
93
|
+
.update(canonicalNodeOutput(plan))
|
|
94
|
+
.digest("hex");
|
|
95
|
+
if (!expectedTaskDigest || approval.taskContractSha256 !== expectedTaskDigest || approval.auditedPlanSha256 !== expectedPlanDigest) {
|
|
96
|
+
return { ok: false, reason: "final write-set approval is stale for the task contract or audited plan", approvalSourceNodeId: binding.approvalSourceNodeId };
|
|
97
|
+
}
|
|
98
|
+
const effectiveWriteSet = approved;
|
|
99
|
+
if (effectiveWriteSet.length === 0 || new Set(effectiveWriteSet).size !== effectiveWriteSet.length) {
|
|
100
|
+
return { ok: false, reason: "final write-set approval must contain a non-empty ordered unique path set", approvalSourceNodeId: binding.approvalSourceNodeId };
|
|
101
|
+
}
|
|
102
|
+
for (const entry of effectiveWriteSet) {
|
|
103
|
+
const normalized = entry.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
104
|
+
if (!normalized || normalized === "." || normalized === ".." || normalized.includes("*") || normalized.includes("?") || normalized.includes("REPLACE/") || normalized.includes("PLACEHOLDER")) {
|
|
105
|
+
return { ok: false, reason: `final write-set approval contains a broad or placeholder path: ${entry}`, approvalSourceNodeId: binding.approvalSourceNodeId };
|
|
106
|
+
}
|
|
107
|
+
if (!input.task.allowedPaths.some((allowed) => pathMatchesPattern(normalized, allowed))) {
|
|
108
|
+
return { ok: false, reason: `final write-set approval exceeds allowedPaths: ${entry}`, approvalSourceNodeId: binding.approvalSourceNodeId };
|
|
109
|
+
}
|
|
110
|
+
if (input.task.forbiddenPaths.some((forbidden) => pathMatchesPattern(normalized, forbidden))) {
|
|
111
|
+
return { ok: false, reason: `final write-set approval overlaps forbiddenPaths: ${entry}`, approvalSourceNodeId: binding.approvalSourceNodeId };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { ok: true, effectiveWriteSet, approvalDigest: approval.approvalDigest };
|
|
115
|
+
}
|
|
21
116
|
export function buildNodePrompt(spec, task, upstream, options) {
|
|
22
117
|
const policy = resolveContextPolicy(spec);
|
|
23
118
|
return buildDagNodePromptEnvelope({
|
|
@@ -193,6 +288,13 @@ function recordRepairArtifactForSupervisorNode(input) {
|
|
|
193
288
|
function sleep(ms) {
|
|
194
289
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
195
290
|
}
|
|
291
|
+
function durationBetween(startedAt, finishedAt) {
|
|
292
|
+
const started = startedAt ? new Date(startedAt).getTime() : Number.NaN;
|
|
293
|
+
const finished = finishedAt ? new Date(finishedAt).getTime() : Number.NaN;
|
|
294
|
+
return Number.isFinite(started) && Number.isFinite(finished)
|
|
295
|
+
? Math.max(0, finished - started)
|
|
296
|
+
: 0;
|
|
297
|
+
}
|
|
196
298
|
function sumAttemptMetric(attempts, select) {
|
|
197
299
|
const values = attempts.map(select).filter((value) => value !== undefined);
|
|
198
300
|
return values.length > 0
|
|
@@ -213,7 +315,7 @@ async function notifyNodeObserver(observer, event, nodeId, state, chunk) {
|
|
|
213
315
|
}
|
|
214
316
|
export async function executeDagNode(input) {
|
|
215
317
|
const { nodeId, tasksById, state, spec, cwd, runDir, executeNode } = input;
|
|
216
|
-
|
|
318
|
+
let task = tasksById.get(nodeId);
|
|
217
319
|
const node = state.nodes[nodeId];
|
|
218
320
|
const failBeforePrompt = async (error, failureCategory) => {
|
|
219
321
|
const failedAt = new Date().toISOString();
|
|
@@ -223,13 +325,42 @@ export async function executeDagNode(input) {
|
|
|
223
325
|
node.failureCategory = failureCategory;
|
|
224
326
|
node.finishedAt = failedAt;
|
|
225
327
|
node.lastActivityAt = node.finishedAt;
|
|
226
|
-
node.durationMs =
|
|
328
|
+
node.durationMs = durationBetween(node.startedAt, node.finishedAt);
|
|
329
|
+
node.timing = { retryBackoffMs: 0, settlementCleanupMs: 0 };
|
|
227
330
|
state.nodes[nodeId].nodeRecordPath = path.join(runDir, `${nodeId}.json`);
|
|
228
331
|
await writeNodeRecord(runDir, nodeId, state.nodes[nodeId]);
|
|
229
332
|
await input.persistState();
|
|
230
333
|
await notifyNodeObserver(input.observer, "onNodeFinish", nodeId, state);
|
|
231
334
|
};
|
|
232
335
|
const failSkillSnapshot = (error) => failBeforePrompt(error, "skill-snapshot-integrity");
|
|
336
|
+
if (task.finalWriteSetApproval) {
|
|
337
|
+
const authorization = parseAndValidateFinalWriteSetApproval({ task, spec, state });
|
|
338
|
+
if (!authorization.ok) {
|
|
339
|
+
node.runtimeWriteAuthorization = {
|
|
340
|
+
schemaVersion: 1,
|
|
341
|
+
status: "rejected",
|
|
342
|
+
approvalSourceNodeId: authorization.approvalSourceNodeId,
|
|
343
|
+
reason: authorization.reason,
|
|
344
|
+
};
|
|
345
|
+
await failBeforePrompt(new Error(`final-write-set-approval-invalid: ${authorization.reason}`), "final-write-set-approval-invalid");
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
task = {
|
|
349
|
+
...task,
|
|
350
|
+
writeSet: authorization.effectiveWriteSet,
|
|
351
|
+
resolvedFinalWriteSetApproval: {
|
|
352
|
+
approvalDigest: authorization.approvalDigest,
|
|
353
|
+
approvalSourceNodeId: task.finalWriteSetApproval.approvalSourceNodeId,
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
node.runtimeWriteAuthorization = {
|
|
357
|
+
schemaVersion: 1,
|
|
358
|
+
status: "validated",
|
|
359
|
+
approvalSourceNodeId: task.finalWriteSetApproval.approvalSourceNodeId,
|
|
360
|
+
approvalDigest: authorization.approvalDigest,
|
|
361
|
+
effectiveWriteSet: [...authorization.effectiveWriteSet],
|
|
362
|
+
};
|
|
363
|
+
}
|
|
233
364
|
let projectGovernanceContext;
|
|
234
365
|
if (task.governanceStandardReview) {
|
|
235
366
|
try {
|
|
@@ -301,7 +432,7 @@ export async function executeDagNode(input) {
|
|
|
301
432
|
await input.persistState();
|
|
302
433
|
await notifyNodeObserver(input.observer, "onNodeStart", nodeId, state);
|
|
303
434
|
if (isDynamicTask) {
|
|
304
|
-
const
|
|
435
|
+
const dynamicExecutionStartedAt = new Date().toISOString();
|
|
305
436
|
try {
|
|
306
437
|
const result = await input.executeDynamicNode({
|
|
307
438
|
task,
|
|
@@ -314,20 +445,17 @@ export async function executeDagNode(input) {
|
|
|
314
445
|
observer: input.observer,
|
|
315
446
|
persistState: input.persistState,
|
|
316
447
|
});
|
|
317
|
-
node.durationMs = result.durationMs ?? Date.now() - started;
|
|
318
448
|
node.stdout = result.stdout;
|
|
319
449
|
node.stderr = result.stderr;
|
|
320
450
|
node.failureCategory = result.failureCategory;
|
|
321
|
-
node.finishedAt = new Date().toISOString();
|
|
322
|
-
node.lastActivityAt = node.finishedAt;
|
|
323
451
|
node.status = result.ok ? "FINISHED" : "ERROR";
|
|
324
452
|
}
|
|
325
453
|
catch (error) {
|
|
326
454
|
node.status = "ERROR";
|
|
327
455
|
node.stderr = error instanceof Error ? error.message : String(error);
|
|
328
|
-
node.finishedAt = new Date().toISOString();
|
|
329
|
-
node.durationMs = Date.now() - started;
|
|
330
456
|
}
|
|
457
|
+
const dynamicExecutionFinishedAt = new Date().toISOString();
|
|
458
|
+
const dynamicExecutionWallDurationMs = durationBetween(dynamicExecutionStartedAt, dynamicExecutionFinishedAt);
|
|
331
459
|
if (node.status === "FINISHED") {
|
|
332
460
|
const artifactMeta = await persistLongNodeOutputArtifacts({
|
|
333
461
|
runDir,
|
|
@@ -337,6 +465,14 @@ export async function executeDagNode(input) {
|
|
|
337
465
|
});
|
|
338
466
|
Object.assign(node, artifactMeta);
|
|
339
467
|
}
|
|
468
|
+
const dynamicFinishedAt = new Date().toISOString();
|
|
469
|
+
node.finishedAt = dynamicFinishedAt;
|
|
470
|
+
node.lastActivityAt = dynamicFinishedAt;
|
|
471
|
+
node.durationMs = durationBetween(node.startedAt, dynamicFinishedAt);
|
|
472
|
+
node.timing = {
|
|
473
|
+
retryBackoffMs: 0,
|
|
474
|
+
settlementCleanupMs: Math.max(0, node.durationMs - dynamicExecutionWallDurationMs),
|
|
475
|
+
};
|
|
340
476
|
state.nodes[nodeId].nodeRecordPath = path.join(runDir, `${nodeId}.json`);
|
|
341
477
|
await writeNodeRecord(runDir, nodeId, state.nodes[nodeId]);
|
|
342
478
|
await input.persistState();
|
|
@@ -382,6 +518,7 @@ export async function executeDagNode(input) {
|
|
|
382
518
|
: undefined;
|
|
383
519
|
const maxAttempts = retryPolicy?.maxAttempts ?? 1;
|
|
384
520
|
const attempts = [];
|
|
521
|
+
let totalAttemptWallDurationMs = 0;
|
|
385
522
|
let totalBackoffMs = 0;
|
|
386
523
|
const livenessPolicy = resolveLivenessPolicy(spec.defaults?.livenessPolicy, task.livenessPolicy);
|
|
387
524
|
/**
|
|
@@ -422,7 +559,6 @@ export async function executeDagNode(input) {
|
|
|
422
559
|
let previousProtocolReason;
|
|
423
560
|
for (let attemptNumber = 1; attemptNumber <= maxAttempts; attemptNumber += 1) {
|
|
424
561
|
const attemptStartedAt = new Date().toISOString();
|
|
425
|
-
const attemptStarted = Date.now();
|
|
426
562
|
node.currentAttempt = attemptNumber;
|
|
427
563
|
node.livenessStatus = "active";
|
|
428
564
|
let result;
|
|
@@ -450,7 +586,7 @@ export async function executeDagNode(input) {
|
|
|
450
586
|
ok: false,
|
|
451
587
|
stdout: "",
|
|
452
588
|
stderr: error instanceof Error ? error.message : String(error),
|
|
453
|
-
durationMs:
|
|
589
|
+
durationMs: 0,
|
|
454
590
|
};
|
|
455
591
|
}
|
|
456
592
|
// R0: executor ok=true still fails closed when outputProtocol is violated.
|
|
@@ -474,11 +610,14 @@ export async function executeDagNode(input) {
|
|
|
474
610
|
}
|
|
475
611
|
}
|
|
476
612
|
const attemptFinishedAt = new Date().toISOString();
|
|
613
|
+
const attemptWallDurationMs = durationBetween(attemptStartedAt, attemptFinishedAt);
|
|
614
|
+
totalAttemptWallDurationMs += attemptWallDurationMs;
|
|
477
615
|
const attemptRecord = {
|
|
478
616
|
attempt: attemptNumber,
|
|
479
617
|
startedAt: attemptStartedAt,
|
|
480
618
|
finishedAt: attemptFinishedAt,
|
|
481
|
-
durationMs:
|
|
619
|
+
durationMs: attemptWallDurationMs,
|
|
620
|
+
timing: { executorDurationMs: result.durationMs },
|
|
482
621
|
ok: result.ok,
|
|
483
622
|
stdout: result.stdout,
|
|
484
623
|
stderr: result.stderr,
|
|
@@ -500,10 +639,7 @@ export async function executeDagNode(input) {
|
|
|
500
639
|
}
|
|
501
640
|
// Reflect the latest attempt on the node so progress is observable,
|
|
502
641
|
// but keep node.status RUNNING while retry is still possible.
|
|
503
|
-
node.durationMs =
|
|
504
|
-
retryPolicy === undefined
|
|
505
|
-
? attemptRecord.durationMs
|
|
506
|
-
: attempts.reduce((sum, attempt) => sum + (attempt.durationMs ?? 0), 0) + totalBackoffMs;
|
|
642
|
+
node.durationMs = durationBetween(node.startedAt, attemptFinishedAt);
|
|
507
643
|
node.stdout = result.stdout;
|
|
508
644
|
node.stderr = result.stderr;
|
|
509
645
|
node.failureCategory = result.failureCategory;
|
|
@@ -543,8 +679,10 @@ export async function executeDagNode(input) {
|
|
|
543
679
|
break;
|
|
544
680
|
const delayMs = computeBackoffDelayMs(attemptNumber + 1, retryPolicy);
|
|
545
681
|
if (delayMs > 0) {
|
|
546
|
-
|
|
682
|
+
const backoffStartedAt = new Date().toISOString();
|
|
547
683
|
await sleep(delayMs);
|
|
684
|
+
const backoffFinishedAt = new Date().toISOString();
|
|
685
|
+
totalBackoffMs += durationBetween(backoffStartedAt, backoffFinishedAt);
|
|
548
686
|
// Keep lastActivityAt fresh so backoff wait is not misread as node-quiet.
|
|
549
687
|
node.lastActivityAt = new Date().toISOString();
|
|
550
688
|
state.nodes[nodeId].nodeRecordPath = path.join(runDir, `${nodeId}.json`);
|
|
@@ -591,7 +729,6 @@ export async function executeDagNode(input) {
|
|
|
591
729
|
}
|
|
592
730
|
}
|
|
593
731
|
const result = terminalResult;
|
|
594
|
-
const started = Date.now();
|
|
595
732
|
try {
|
|
596
733
|
recordRepairArtifactForSupervisorNode({
|
|
597
734
|
task,
|
|
@@ -602,8 +739,6 @@ export async function executeDagNode(input) {
|
|
|
602
739
|
if (outputChunk.trim()) {
|
|
603
740
|
await notifyNodeObserver(input.observer, "onNodeOutput", nodeId, state, outputChunk);
|
|
604
741
|
}
|
|
605
|
-
node.finishedAt = new Date().toISOString();
|
|
606
|
-
node.lastActivityAt = node.finishedAt;
|
|
607
742
|
node.status = result.ok ? "FINISHED" : "ERROR";
|
|
608
743
|
if (result.ok) {
|
|
609
744
|
const decisionRecord = await recordDecisionEnvelopeForNode({
|
|
@@ -645,9 +780,19 @@ export async function executeDagNode(input) {
|
|
|
645
780
|
catch (error) {
|
|
646
781
|
node.status = "ERROR";
|
|
647
782
|
node.stderr = error instanceof Error ? error.message : String(error);
|
|
648
|
-
node.finishedAt = new Date().toISOString();
|
|
649
|
-
node.durationMs = Date.now() - started;
|
|
650
783
|
}
|
|
784
|
+
// Attempt artifacts must observe their own persisted boundaries before the
|
|
785
|
+
// potentially asynchronous artifact write; post-attempt work belongs to node timing.
|
|
786
|
+
const terminalFinishedAt = new Date().toISOString();
|
|
787
|
+
node.finishedAt = terminalFinishedAt;
|
|
788
|
+
node.lastActivityAt = terminalFinishedAt;
|
|
789
|
+
node.durationMs = durationBetween(node.startedAt, terminalFinishedAt);
|
|
790
|
+
node.timing = {
|
|
791
|
+
retryBackoffMs: totalBackoffMs,
|
|
792
|
+
settlementCleanupMs: Math.max(0, durationBetween(node.startedAt, terminalFinishedAt) -
|
|
793
|
+
totalAttemptWallDurationMs -
|
|
794
|
+
totalBackoffMs),
|
|
795
|
+
};
|
|
651
796
|
if (node.status === "FINISHED") {
|
|
652
797
|
const artifactMeta = await persistLongNodeOutputArtifacts({
|
|
653
798
|
runDir,
|
|
@@ -681,6 +826,16 @@ export async function executeDagNode(input) {
|
|
|
681
826
|
}
|
|
682
827
|
}
|
|
683
828
|
}
|
|
829
|
+
const finishedAt = new Date().toISOString();
|
|
830
|
+
node.finishedAt = finishedAt;
|
|
831
|
+
node.lastActivityAt = finishedAt;
|
|
832
|
+
node.durationMs = durationBetween(node.startedAt, finishedAt);
|
|
833
|
+
node.timing = {
|
|
834
|
+
...node.timing,
|
|
835
|
+
settlementCleanupMs: Math.max(0, durationBetween(node.startedAt, finishedAt) -
|
|
836
|
+
totalAttemptWallDurationMs -
|
|
837
|
+
totalBackoffMs),
|
|
838
|
+
};
|
|
684
839
|
state.nodes[nodeId].nodeRecordPath = path.join(runDir, `${nodeId}.json`);
|
|
685
840
|
await writeNodeRecord(runDir, nodeId, state.nodes[nodeId]);
|
|
686
841
|
await input.persistState();
|
|
@@ -48,6 +48,28 @@ export const dagShellVerifyEvidenceSchema = z.object({
|
|
|
48
48
|
commandCount: z.number().int().nonnegative(),
|
|
49
49
|
commandLabels: z.array(z.string()).default([]),
|
|
50
50
|
commandTexts: z.array(z.string()).default([]),
|
|
51
|
+
/** Semantic command identities in the same deterministic order as commandTexts. */
|
|
52
|
+
canonicalKeys: z.array(z.string()).default([]),
|
|
53
|
+
/** Commands merged into a retained semantic command, keyed by canonical identity. */
|
|
54
|
+
mergedCommands: z
|
|
55
|
+
.array(z.object({
|
|
56
|
+
canonicalKey: z.string(),
|
|
57
|
+
keptLabel: z.string(),
|
|
58
|
+
mergedLabels: z.array(z.string()),
|
|
59
|
+
/** Retained timeout after semantic duplicates merge, or null when unspecified. */
|
|
60
|
+
effectiveTimeoutMs: z.number().int().positive().nullable(),
|
|
61
|
+
}))
|
|
62
|
+
.default([]),
|
|
63
|
+
/** Commands omitted only because a retained aggregate is known to cover them. */
|
|
64
|
+
coveredCommands: z
|
|
65
|
+
.array(z.object({ aggregateLabel: z.string(), coveredLabel: z.string() }))
|
|
66
|
+
.default([]),
|
|
67
|
+
/** Why a command was retained or deferred from the intermediate verify node. */
|
|
68
|
+
selectionReasons: z.array(z.string()).default([]),
|
|
69
|
+
/** Non-executing generation-time cwd/local-script validation evidence. */
|
|
70
|
+
preflight: z
|
|
71
|
+
.array(z.object({ label: z.string(), status: z.enum(["ok", "future-delivery-script"]) }))
|
|
72
|
+
.default([]),
|
|
51
73
|
/** Effective timeout applied independently to each shell command. */
|
|
52
74
|
commandTimeoutMs: z.number().int().positive().optional(),
|
|
53
75
|
/** Worst-case serial budget: commandCount * commandTimeoutMs. */
|
|
@@ -344,6 +366,13 @@ export const dagFrontendBrowserToolPreflightSchema = z.object({}).strict();
|
|
|
344
366
|
export const dagFrontendTestEvidenceValidationSchema = z.object({}).strict();
|
|
345
367
|
export const dagFrontendTestCaseChecklistSchema = z.object({}).strict();
|
|
346
368
|
export const dagFrontendTestHtmlReportSchema = z.object({}).strict();
|
|
369
|
+
export const dagFinalWriteSetApprovalGateSchema = z
|
|
370
|
+
.object({
|
|
371
|
+
writerNodeId: z
|
|
372
|
+
.string()
|
|
373
|
+
.regex(/^[a-z][a-z0-9-]*$/, "writerNodeId must be kebab-case"),
|
|
374
|
+
})
|
|
375
|
+
.strict();
|
|
347
376
|
export const dagMavenWorkspaceManifestSchema = z.object({
|
|
348
377
|
files: z.array(z.object({
|
|
349
378
|
path: z.string(),
|
|
@@ -393,6 +422,7 @@ export const dagShellConfigSchema = z.object({
|
|
|
393
422
|
frontendTestCaseChecklist: dagFrontendTestCaseChecklistSchema.optional(),
|
|
394
423
|
frontendBrowserToolPreflight: dagFrontendBrowserToolPreflightSchema.optional(),
|
|
395
424
|
frontendTestEvidenceValidation: dagFrontendTestEvidenceValidationSchema.optional(),
|
|
425
|
+
finalWriteSetApprovalGate: dagFinalWriteSetApprovalGateSchema.optional(),
|
|
396
426
|
frontendTestL5Report: z.object({}).strict().optional(),
|
|
397
427
|
frontendTestHtmlReport: dagFrontendTestHtmlReportSchema.optional(),
|
|
398
428
|
backendTestPipeline: dagBackendTestPipelineSchema.optional(),
|
|
@@ -541,6 +571,24 @@ export const dagWriterOutcomePolicySchema = z
|
|
|
541
571
|
requireChangedFiles: z.boolean().optional(),
|
|
542
572
|
})
|
|
543
573
|
.strict();
|
|
574
|
+
/**
|
|
575
|
+
* The final supervised audit is the sole runtime write authority for its
|
|
576
|
+
* writer. `allowedPaths` remains a ceiling; it is never a writer fallback.
|
|
577
|
+
*/
|
|
578
|
+
export const dagFinalWriteSetApprovalBindingSchema = z
|
|
579
|
+
.object({
|
|
580
|
+
schemaVersion: z.literal(1),
|
|
581
|
+
writerNodeId: z
|
|
582
|
+
.string()
|
|
583
|
+
.regex(/^[a-z][a-z0-9-]*$/, "writerNodeId must be kebab-case"),
|
|
584
|
+
approvalSourceNodeId: z
|
|
585
|
+
.string()
|
|
586
|
+
.regex(/^[a-z][a-z0-9-]*$/, "approvalSourceNodeId must be kebab-case"),
|
|
587
|
+
auditedPlanNodeId: z
|
|
588
|
+
.string()
|
|
589
|
+
.regex(/^[a-z][a-z0-9-]*$/, "auditedPlanNodeId must be kebab-case"),
|
|
590
|
+
})
|
|
591
|
+
.strict();
|
|
544
592
|
export const dagConvergenceSpecSchema = z
|
|
545
593
|
.object({
|
|
546
594
|
enabled: z.boolean().optional().default(false),
|
|
@@ -608,6 +656,16 @@ export const dagTaskSchema = z.object({
|
|
|
608
656
|
* run-attributed diff.
|
|
609
657
|
*/
|
|
610
658
|
writerOutcomePolicy: dagWriterOutcomePolicySchema.optional(),
|
|
659
|
+
/** Required for supervised writers that consume a final audited writeSet. */
|
|
660
|
+
finalWriteSetApproval: dagFinalWriteSetApprovalBindingSchema.optional(),
|
|
661
|
+
/** Runtime-only authorization facts injected after final approval validation. */
|
|
662
|
+
resolvedFinalWriteSetApproval: z
|
|
663
|
+
.object({
|
|
664
|
+
approvalDigest: z.string().regex(/^[a-f0-9]{64}$/),
|
|
665
|
+
approvalSourceNodeId: z.string().min(1),
|
|
666
|
+
})
|
|
667
|
+
.strict()
|
|
668
|
+
.optional(),
|
|
611
669
|
/**
|
|
612
670
|
* Explicit opt-in for the deterministic project governance context resolver
|
|
613
671
|
* (AGENTS.md chain + referenced code standards). Only tasks that set this
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
## 入口
|
|
6
6
|
|
|
7
7
|
- [`development-principles.md`](development-principles.md):工程原则与完成纪律。
|
|
8
|
-
- [`feature-workflow.md`](feature-workflow.md):会话治理、taskKind、profile
|
|
8
|
+
- [`feature-workflow.md`](feature-workflow.md):会话治理、taskKind、profile、Agent DAG 路径,以及 managed contract 的边界更新与恢复语法。
|
|
9
9
|
- [`verification-matrix.md`](verification-matrix.md):按变更类型选择验证命令。
|
|
10
10
|
- [`harness-methodology-tdd.md`](harness-methodology-tdd.md):测试先行的方法与边界。
|
|
11
11
|
- [`harness-methodology-verification.md`](harness-methodology-verification.md):如何用新鲜命令结果支持完成声明。
|
|
@@ -282,8 +282,8 @@
|
|
|
282
282
|
},
|
|
283
283
|
"executors": {
|
|
284
284
|
"type": "object",
|
|
285
|
-
"description": "
|
|
286
|
-
"additionalProperties":
|
|
285
|
+
"description": "受治理 runtime 仅支持 pi;Cursor 仅通过 cursor-prompt sidecar 使用,不得配置在 executors 中。",
|
|
286
|
+
"additionalProperties": false,
|
|
287
287
|
"properties": {
|
|
288
288
|
"pi": { "$ref": "#/$defs/executor" }
|
|
289
289
|
}
|
|
@@ -319,13 +319,13 @@
|
|
|
319
319
|
"executor": {
|
|
320
320
|
"type": "object",
|
|
321
321
|
"additionalProperties": false,
|
|
322
|
-
"description": "executor 设置。DAG 模型选择优先读取 LOW/MED/HIGH,其次读取 defaultModel,最后回退到 loop-agent runtime
|
|
322
|
+
"description": "Pi executor 设置。DAG 模型选择优先读取 LOW/MED/HIGH,其次读取 defaultModel,最后回退到 loop-agent runtime 默认矩阵。fresh init 只写 LOW/MED/HIGH;三档有效值齐全时不保留 defaultModel。defaultModel 仅兼容旧项目。字面值 default 表示不覆盖。LOW/MED/HIGH 可为字符串或 { model, thinking? } 对象。",
|
|
323
323
|
"properties": {
|
|
324
324
|
"description": { "type": "string" },
|
|
325
325
|
"enabled": { "type": "boolean" },
|
|
326
326
|
"defaultModel": {
|
|
327
327
|
"type": "string",
|
|
328
|
-
"description": "
|
|
328
|
+
"description": "旧项目兼容默认模型。fresh init 不输出该字段;设置为 default 或省略时,会继续回退到 loop-agent runtime 默认值。",
|
|
329
329
|
"default": "default"
|
|
330
330
|
},
|
|
331
331
|
"LOW": {
|
|
@@ -339,10 +339,6 @@
|
|
|
339
339
|
"HIGH": {
|
|
340
340
|
"$ref": "#/$defs/executorTierModel",
|
|
341
341
|
"description": "HIGH 复杂度 DAG task 的模型覆盖值,优先级高于 defaultModel。可为模型字符串,或 { model, thinking? } 对象。"
|
|
342
|
-
},
|
|
343
|
-
"requiresApiKey": {
|
|
344
|
-
"type": "string",
|
|
345
|
-
"description": "该 executor 需要的环境变量名,通常用于 Cursor 等可选外部 executor。init update 会剥离过时的 pi.requiresApiKey。"
|
|
346
342
|
}
|
|
347
343
|
}
|
|
348
344
|
}
|
package/package.json
CHANGED
|
@@ -128,7 +128,8 @@ SDK 回归或 SDK 可选依赖不可用时用 `cli-only` 诊断。CLI fallback
|
|
|
128
128
|
|
|
129
129
|
```bash
|
|
130
130
|
loop-agent init instructions --repo-root <target-repo>
|
|
131
|
-
loop-agent init --repo-root <target-repo> --profile full --merge
|
|
131
|
+
loop-agent init --repo-root <target-repo> --profile full --merge \
|
|
132
|
+
--provider deepseek --model deepseek/deepseek-v4-flash
|
|
132
133
|
loop-agent init doctor --repo-root <target-repo>
|
|
133
134
|
loop-agent init check-update --repo-root <target-repo> --json
|
|
134
135
|
loop-agent init check-update --repo-root <target-repo> --markdown
|
|
@@ -141,9 +142,11 @@ loop-agent init upgrade --repo-root <target-repo> --run-id <run-id> --continue -
|
|
|
141
142
|
loop-agent init upgrade --repo-root <target-repo> --run-id <run-id> --report --markdown
|
|
142
143
|
```
|
|
143
144
|
|
|
145
|
+
fresh init 将精确引用写到 `executors.pi.LOW/MED/HIGH`。fresh `loop-agent init` 会将合格且完整的 `provider/model` 引用投影到 `executors.pi.LOW/MED/HIGH`,即 `executors.pi.LOW`、`executors.pi.MED` 和 `executors.pi.HIGH`。三个 tier 齐全时不会写入 `defaultModel`,也不会恢复或建议旧顶层 routing 字段。
|
|
146
|
+
|
|
144
147
|
`init check-update` 是只读升级报告,用于发现目标项目是否落后于当前包内初始化 surface。输出会区分 deterministic actions、model merge tasks、human decisions 和 recommended next。`--markdown` 会渲染可直接交给模型执行的合并指引,包含 `allowedPaths`、`forbiddenPaths`、`mergeRules` 和 `verification`。
|
|
145
148
|
|
|
146
|
-
`init update --bootstrap-surface` 为旧项目写入 `.harness/init-surface.json` 的 `inferred-baseline`,不伪装成历史 recorded baseline。`init update --apply-safe` 只执行确定性安全动作:补缺失文件、创建目录、刷新 managed block;已有但无法确认与当前包一致的文件会进入 model merge tasks
|
|
149
|
+
`init update --bootstrap-surface` 为旧项目写入 `.harness/init-surface.json` 的 `inferred-baseline`,不伪装成历史 recorded baseline。`init update --apply-safe` 只执行确定性安全动作:补缺失文件、创建目录、刷新 managed block;已有但无法确认与当前包一致的文件会进入 model merge tasks,不会被覆盖。模型 merge 只会自动展开不含 tier 或其他旧字段的唯一、完整且可证明等价的旧 `defaultModel: "provider/model"`;顶层 `model`、`models`、`modelProfiles`、`modelRouting`、`verify`、`sequentialWorkflowRole`、部分/对象 tier、bare model、冲突 provider 或未知语义均 fail closed,并保留原始 `harness.json`。
|
|
147
150
|
|
|
148
151
|
`init upgrade` 是写入型升级入口:首次运行冻结 controller identity、检查 npm latest、扫描 surface、自动执行安全动作,并把 run facts 持久化到 `.harness/init-upgrades/<run-id>/`。`--status` 与 `--report` 严格只读;`--continue` 会重新核验 identity、当前 hash 与不变量。版本选择、无法判定冲突、凭据、破坏性删除或 active writer 才进入 human decision。普通语义冲突返回具体单文件 `allowedPaths` merge task,主会话必须合并后继续,不能把 `needs-model-merge` 作为完成。默认管理项目级 OpenCode/Pi recovery 与 `.pi/settings.json` nested merge,保留显式 disabled,Pi trust 后才加载;默认不读写 `~/.pi/agent/settings.json`。旧 `init reconcile` 保留为兼容低层诊断。
|
|
149
152
|
|
|
@@ -4,22 +4,29 @@
|
|
|
4
4
|
|
|
5
5
|
## 模型配置
|
|
6
6
|
|
|
7
|
-
模型设置来自 repo `harness.json
|
|
7
|
+
模型设置来自 repo `harness.json.executors.pi.LOW|MED|HIGH`。新 DAG 不读取顶层 `model`、`models`、`modelProfiles`、`modelRouting`、`verify` 或 `sequentialWorkflowRole`;JSON Schema 与 runtime Zod 都拒绝这些字段。
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
fresh init 的完整三档示例:
|
|
10
10
|
|
|
11
11
|
```json
|
|
12
12
|
{
|
|
13
|
-
"
|
|
14
|
-
|
|
13
|
+
"executors": {
|
|
14
|
+
"pi": {
|
|
15
|
+
"LOW": "wizard-local/gpt-5.3-codex-spark",
|
|
16
|
+
"MED": "deepseek/deepseek-v4-flash",
|
|
17
|
+
"HIGH": "provider/high-reasoning-model"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
15
20
|
}
|
|
16
21
|
```
|
|
17
22
|
|
|
23
|
+
三档齐全时不得保留 `defaultModel`。该字段仅为旧项目的 runtime fallback 兼容保留,不是 fresh-init 输出。旧项目只有不含 tier 或其他旧字段的单一完整 `defaultModel` 可自动展开;对象/部分 tier、旧顶层字段、bare model、冲突或未知 Pi 语义一律保留原始 `harness.json`,进入 model merge/human decision。
|
|
24
|
+
|
|
18
25
|
Agent DAG node 的模型来自 DAG JSON 中的 `executorModels`,并由 `dag validate --strict-models` 检查 canonical matrix 漂移。若变更模型配置,须同步更新 `harness.json`、相关测试、repo docs 与本 skill。
|
|
19
26
|
|
|
20
|
-
`
|
|
27
|
+
`executors.pi` 的每档值支持字符串或 `{ model, thinking? }`。CLI 显式配置必须使用 `--model provider/model`,或成对使用 `--provider provider --model model`;空值、仅 provider、裸 `--model` 与冲突的重复 provider 限定会被拒绝。`provider/model` 只在第一个 `/` 处分割并保留模型 ID 的剩余部分。provider 定义与凭证仍归 Pi 的 `~/.pi/agent/models.json` 和环境配置所有。
|
|
21
28
|
|
|
22
|
-
`pi-prompt` 是独立 one-shot helper,不使用
|
|
29
|
+
`pi-prompt` 是独立 one-shot helper,不使用 harness Pi tier matrix 或 DAG `executorModels`。当前默认是 `wizard-local/glm-5.2`;高复杂度 one-shot 显式传 `--model gpt-5.5`。Agent DAG `pi` executor 的 canonical matrix 保持;运行时优先级仍为 `LOW|MED|HIGH → defaultModel → DEFAULT_DAG_EXECUTOR_MODELS`:
|
|
23
30
|
|
|
24
31
|
```json
|
|
25
32
|
{
|
|
@@ -33,6 +40,6 @@ Agent DAG node 的模型来自 DAG JSON 中的 `executorModels`,并由 `dag va
|
|
|
33
40
|
```bash
|
|
34
41
|
loop-agent inspect
|
|
35
42
|
```
|
|
36
|
-
并在输出中核对 `
|
|
43
|
+
并在输出中核对 `executors.pi.LOW/MED/HIGH` 与 Agent DAG 文档中的 `executorModels` 约定。
|
|
37
44
|
|
|
38
45
|
Pi SDK runtime reuse 仍为 **default-off**(`CODE_AGENT_PI_REUSE_RUNTIME` 未设/`off`/未知)。opt-in 须显式 `auto-run`;`CODE_AGENT_PI_BACKEND=cli-only` 绕过 reuse。无 live call 的确定性 M2/M3 decision 摘要用 `pi-reuse-benchmark`(见 `command-reference.md`)。
|