@tea-agent/loop-agent 0.33.5 → 0.33.6
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 +33 -0
- package/dist/application/task-lifecycle/advance.js +254 -4
- package/dist/application/task-lifecycle/gates.js +50 -0
- package/dist/application/task-lifecycle/observe.js +11 -2
- package/dist/commands/init-upgrade.js +32 -1
- package/dist/commands/init.js +94 -3
- package/dist/executors/shell-write-guard.js +26 -8
- package/dist/shared/operator/capabilities.js +72 -42
- package/dist/shared/resilient-git.js +133 -0
- package/dist/task/source-prepare/artifact-meta.js +137 -0
- package/dist/task/source-prepare/index.js +2 -0
- package/dist/task/source-prepare/parse-intent.js +58 -10
- package/dist/task/source-prepare/prepare.js +180 -16
- package/dist/task/source-prepare/reference-integrity.js +18 -2
- package/dist/task/source-prepare/semantic-intake.js +404 -0
- package/dist/worker/console/app-data.js +2 -0
- package/dist/worker/console/chat/chat-event-store.js +190 -25
- package/dist/worker/console/chat/pi-console-config.js +250 -32
- package/dist/worker/console/chat/pi-runtime.js +625 -71
- package/dist/worker/console/chat/resource-loader.js +5 -4
- package/dist/worker/console/chat/routes.js +324 -146
- package/dist/worker/console/chat/runtime-context.js +48 -12
- package/dist/worker/console/chat/runtime-selection.js +59 -0
- package/dist/worker/console/chat/shortcuts.js +1 -0
- package/dist/worker/console/chat/tool-adapter.js +9 -3
- package/dist/worker/console/chat/tools.js +5 -1
- package/dist/worker/console/dag-execution-receipt.js +380 -0
- package/dist/worker/console/operator-actions.js +559 -68
- package/dist/worker/console/server.js +8 -15
- package/dist/worker/console/static/assets/index-BUOLppPr.js +28 -0
- package/dist/worker/console/static/assets/index-C1KzazY5.css +1 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +45 -8
- package/dist/worker/console/static-src/operator-chat/refs.js +9 -0
- package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +257 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +16 -0
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +210 -184
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +49 -5
- package/dist/worker/console/static-src/operator-chat/useComposer.js +17 -0
- package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +225 -74
- package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +196 -0
- package/dist/worker/delivery/final-verification.js +13 -5
- package/dist/worker/delivery/package.js +31 -19
- package/dist/worker/delivery/verification-bundle.js +6 -4
- package/dist/worker/observe/static/operator-chrome.css +5 -2
- package/dist/worker/observe/static/operator-chrome.js +6 -1
- package/dist/worker/observe/static/styles.css +39 -9
- package/dist/workflows/dag/frontend-worktree-diff.js +12 -27
- package/dist/workflows/dag/workspace-checkpoint.js +8 -27
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/loop-agent/references/command-reference.md +3 -1
- package/skills/loop-agent/references/source-and-plan-practice.md +13 -0
- package/skills/loop-agent/references/task-workflow.md +4 -0
- package/dist/worker/console/chat/instruction-skills.js +0 -217
- package/dist/worker/console/static/assets/index-CnUXAqxG.css +0 -1
- package/dist/worker/console/static/assets/index-CteJFFL2.js +0 -29
|
@@ -24,9 +24,41 @@ function fullText(text, max = RUNTIME_CONTEXT_TEXT_MAX) {
|
|
|
24
24
|
return cleaned;
|
|
25
25
|
return `${cleaned.slice(0, max)}\n\n…[truncated ${cleaned.length - max} chars for inspect UI]`;
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Secret-scrub + length-limit a runtime text (system prompt / resource paths)
|
|
29
|
+
* before it leaves the server. Used by the runtime snapshot projection.
|
|
30
|
+
*/
|
|
31
|
+
export function redactRuntimeText(text, max = RUNTIME_CONTEXT_TEXT_MAX) {
|
|
32
|
+
if (!text)
|
|
33
|
+
return "";
|
|
34
|
+
const cleaned = redact(text);
|
|
35
|
+
if (cleaned.length <= max)
|
|
36
|
+
return cleaned;
|
|
37
|
+
return `${cleaned.slice(0, max)}\n\n…[truncated ${cleaned.length - max} chars for inspect UI]`;
|
|
38
|
+
}
|
|
27
39
|
export function projectRuntimeContext(input) {
|
|
28
40
|
const systemText = fullText(input.systemPrompt) ?? "";
|
|
29
41
|
const suffixText = fullText(input.systemPromptSuffix);
|
|
42
|
+
const projectResources = (resources) => (resources ?? []).map((resource) => ({
|
|
43
|
+
name: redactRuntimeText(resource.name, 200),
|
|
44
|
+
description: redactRuntimeText(resource.description, 600),
|
|
45
|
+
path: redactRuntimeText(resource.path, 400),
|
|
46
|
+
baseDir: resource.baseDir ? redactRuntimeText(resource.baseDir, 400) : undefined,
|
|
47
|
+
scope: resource.scope,
|
|
48
|
+
source: redactRuntimeText(resource.source, 200),
|
|
49
|
+
enabled: resource.enabled,
|
|
50
|
+
version: resource.version ? redactRuntimeText(resource.version, 80) : undefined,
|
|
51
|
+
cwd: resource.cwd ? redactRuntimeText(resource.cwd, 400) : undefined,
|
|
52
|
+
resolved: resource.resolved?.map((item) => ({
|
|
53
|
+
type: item.type,
|
|
54
|
+
name: redactRuntimeText(item.name, 200),
|
|
55
|
+
path: redactRuntimeText(item.path, 400),
|
|
56
|
+
source: redactRuntimeText(item.source, 200),
|
|
57
|
+
scope: item.scope,
|
|
58
|
+
})),
|
|
59
|
+
error: resource.error ? redactRuntimeText(resource.error, 400) : undefined,
|
|
60
|
+
diagnostics: (resource.diagnostics ?? []).map((diag) => redactRuntimeText(diag, 400)),
|
|
61
|
+
}));
|
|
30
62
|
return {
|
|
31
63
|
readOnly: true,
|
|
32
64
|
systemPrompt: {
|
|
@@ -35,22 +67,26 @@ export function projectRuntimeContext(input) {
|
|
|
35
67
|
text: systemText,
|
|
36
68
|
charCount: input.systemPrompt.length,
|
|
37
69
|
},
|
|
38
|
-
skills: input.skills.map((skill) => ({
|
|
39
|
-
name: skill.name,
|
|
40
|
-
description: summary(skill.description, 200) ?? "",
|
|
41
|
-
charCount: skill.charCount,
|
|
42
|
-
...(skill.bodyText
|
|
43
|
-
? { body: fullText(skill.bodyText, 40_000) }
|
|
44
|
-
: {}),
|
|
45
|
-
})),
|
|
46
70
|
resources: {
|
|
47
|
-
mode: "
|
|
48
|
-
noContextFiles:
|
|
49
|
-
noSkills:
|
|
50
|
-
noExtensions:
|
|
71
|
+
mode: "open",
|
|
72
|
+
noContextFiles: false,
|
|
73
|
+
noSkills: false,
|
|
74
|
+
noExtensions: false,
|
|
51
75
|
hasBash: true,
|
|
52
76
|
activeToolCount: input.activeTools.length,
|
|
53
77
|
activeTools: [...input.activeTools],
|
|
78
|
+
contextFiles: (input.contextFiles ?? []).map((file) => ({
|
|
79
|
+
path: redactRuntimeText(file.path, 400),
|
|
80
|
+
characters: file.characters,
|
|
81
|
+
})),
|
|
82
|
+
skills: projectResources(input.skills),
|
|
83
|
+
extensions: projectResources(input.extensions),
|
|
84
|
+
packages: projectResources(input.packages),
|
|
85
|
+
diagnostics: (input.diagnostics ?? []).map((diag) => ({
|
|
86
|
+
type: diag.type,
|
|
87
|
+
message: redactRuntimeText(diag.message, 400),
|
|
88
|
+
...(diag.path ? { path: redactRuntimeText(diag.path, 400) } : {}),
|
|
89
|
+
})),
|
|
54
90
|
},
|
|
55
91
|
model: input.model,
|
|
56
92
|
thinkingLevel: input.thinkingLevel,
|
|
@@ -7,6 +7,65 @@
|
|
|
7
7
|
* in a plain Node environment (the React component owns the network race
|
|
8
8
|
* guards; these helpers only define what "matches" and "merged" mean).
|
|
9
9
|
*/
|
|
10
|
+
/** Human-readable thinking-level labels (auto is always the first option). */
|
|
11
|
+
export const THINKING_LEVEL_LABELS = {
|
|
12
|
+
auto: "auto(跟随 Pi 默认等级)",
|
|
13
|
+
off: "off · 关闭",
|
|
14
|
+
minimal: "minimal · 极简",
|
|
15
|
+
low: "low · 低",
|
|
16
|
+
medium: "medium · 中",
|
|
17
|
+
high: "high · 高",
|
|
18
|
+
xhigh: "xhigh · 极高",
|
|
19
|
+
max: "max · 最高",
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Group (and optionally filter) models by provider, preserving the provider
|
|
23
|
+
* order of first appearance and the model order within each provider.
|
|
24
|
+
* The query matches provider id, model id and display name (case-insensitive).
|
|
25
|
+
* Empty query returns all models grouped — never a fake "no models" list.
|
|
26
|
+
*/
|
|
27
|
+
export function groupModelsByProvider(models, query) {
|
|
28
|
+
const q = query.trim().toLowerCase();
|
|
29
|
+
const groups = new Map();
|
|
30
|
+
for (const model of models) {
|
|
31
|
+
if (q &&
|
|
32
|
+
!model.provider.toLowerCase().includes(q) &&
|
|
33
|
+
!model.id.toLowerCase().includes(q) &&
|
|
34
|
+
!(model.name ?? "").toLowerCase().includes(q)) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const list = groups.get(model.provider) ?? [];
|
|
38
|
+
list.push(model);
|
|
39
|
+
groups.set(model.provider, list);
|
|
40
|
+
}
|
|
41
|
+
return [...groups.entries()].map(([provider, models]) => ({
|
|
42
|
+
provider,
|
|
43
|
+
models,
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Build the thinking menu options: `auto` is always the first option, followed
|
|
48
|
+
* by the runtime-supported levels in their canonical order. `effective` is the
|
|
49
|
+
* Pi actual thinking level (shown as a badge on the auto entry when provided).
|
|
50
|
+
*/
|
|
51
|
+
export function buildThinkingMenuOptions(levels, effective) {
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
const options = [];
|
|
54
|
+
const push = (value) => {
|
|
55
|
+
if (seen.has(value))
|
|
56
|
+
return;
|
|
57
|
+
seen.add(value);
|
|
58
|
+
options.push({
|
|
59
|
+
value,
|
|
60
|
+
label: THINKING_LEVEL_LABELS[value] ?? value,
|
|
61
|
+
...(value === "auto" && effective ? { effective } : {}),
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
push("auto");
|
|
65
|
+
for (const level of levels)
|
|
66
|
+
push(level);
|
|
67
|
+
return options;
|
|
68
|
+
}
|
|
10
69
|
/** Format a model reference for `<option value>` keys and equality checks. */
|
|
11
70
|
export function formatModelRef(provider, modelId) {
|
|
12
71
|
return `${provider}/${modelId}`;
|
|
@@ -3,6 +3,7 @@ export const SHORTCUTS = [
|
|
|
3
3
|
{ command: "/contract", label: "Contract", description: "查看 contract diff 与 apply gate", action: "focus-panel:contract" },
|
|
4
4
|
{ command: "/dag", label: "DAG", description: "查看 DAG spine、确认与运行状态", action: "focus-panel:dag" },
|
|
5
5
|
{ command: "/interview", label: "Interview", description: "打开 Requirement Interview", action: "focus:interview" },
|
|
6
|
+
{ command: "/reload", label: "Reload session", description: "重新加载当前 Pi 会话的设置、扩展、技能、提示词、主题与上下文文件(不产生消息)", action: "reload" },
|
|
6
7
|
{ command: "/help", label: "Shortcuts", description: "显示可用 shortcut", action: "show-help" },
|
|
7
8
|
];
|
|
8
9
|
/** Pure navigation/display resolver. It deliberately has no mutation decisions. */
|
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
*
|
|
10
10
|
* High-risk / long-running actions ARE mapped as tools (2026-07-25 widening),
|
|
11
11
|
* but the registry now declares their modelCallable policy:
|
|
12
|
-
* - "always": model can invoke; dispatch runs server-side
|
|
12
|
+
* - "always": model can invoke; dispatch runs server-side (2026-08-11:
|
|
13
|
+
* runDag/dagRerun/standaloneTaskRerun/workerTaskRetry are autonomous —
|
|
14
|
+
* runDag consumes a server-issued single-use execution receipt, and the
|
|
15
|
+
* recovery actions pass server-side read-only run-facts checks).
|
|
13
16
|
* - "prepare-only": model can invoke but the mutation needs a human-origin
|
|
14
|
-
* confirmation (
|
|
15
|
-
* confirm via the browser mutation gate — M0-B).
|
|
17
|
+
* confirmation (Night Scheduler mutation-gate faces).
|
|
16
18
|
* - "never": the action is the human confirmation itself (confirmDagConfirmation)
|
|
17
19
|
* and MUST NOT appear as a model-callable tool (M0-B / roadmap G03).
|
|
18
20
|
*
|
|
@@ -30,6 +32,10 @@ import { buildOperatorCapabilitiesDocument } from "../../../shared/operator/capa
|
|
|
30
32
|
* prepare helpers (prepareDagConfirmation / prepareMutationGate); the browser
|
|
31
33
|
* mutation gate + HMAC token perform the consuming dispatch.
|
|
32
34
|
*
|
|
35
|
+
* 2026-08-11: runDag / dagRerun / standaloneTaskRerun / workerTaskRetry are no
|
|
36
|
+
* longer gate-only (autonomous execution + run-facts checks) and drop out of
|
|
37
|
+
* this derived list automatically.
|
|
38
|
+
*
|
|
33
39
|
* `contractApply` is also gate-only even though its registry face remains
|
|
34
40
|
* `humanConfirmation: "conditional"` (assessment soft-gate for workspace UI);
|
|
35
41
|
* Chat executes it only via the contract-apply Human Gate route.
|
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
* Operator Chat registers and activates full Pi builtins
|
|
5
5
|
* (read/write/edit/bash/grep/find/ls) plus model-callable operator actions and
|
|
6
6
|
* optional safe-* explore custom tools. Discipline is primarily soft
|
|
7
|
-
* (system prompt: prefer DAG
|
|
7
|
+
* (system prompt: prefer DAG paths). Hard denies remain for non-Pi
|
|
8
8
|
* write channels (apply_patch / full-tools / shell / coding-chat) and for
|
|
9
9
|
* modelCallable="never" actions (e.g. confirmDagConfirmation).
|
|
10
10
|
*
|
|
11
|
+
* 2026-08-11: runDag / dagRerun / standaloneTaskRerun / workerTaskRetry are
|
|
12
|
+
* model-callable (autonomous DAG execution); the browser Human Gate still
|
|
13
|
+
* covers Night Scheduler mutations, contractApply and confirmDagConfirmation.
|
|
14
|
+
*
|
|
11
15
|
* Interview / Official surfaces keep their own OFFICIAL_DENIED path; this
|
|
12
16
|
* module only filters Chat-side denial so Interview is not widened.
|
|
13
17
|
*/
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DAG execution receipt — session-bound, TTL, single-use autonomous DAG
|
|
3
|
+
* execution (2026-08-11 design §5 / §8.3).
|
|
4
|
+
*
|
|
5
|
+
* `prepareDagExecution` issues an execution receipt ONLY when the server-side
|
|
6
|
+
* G2 autonomous-execution assessment passes (bounded writeSet ⊆ allowedPaths,
|
|
7
|
+
* no forbidden overlap, no broad/destructive risk, structured verification
|
|
8
|
+
* present, valid bindings). The receipt carries no human-gate token — the
|
|
9
|
+
* browser Human Gate is not part of this flow.
|
|
10
|
+
*
|
|
11
|
+
* `runDag` consumes the receipt exactly once. Consumption re-reads the staged
|
|
12
|
+
* DAG bytes/hash and re-validates every binding (dag hash, source binding,
|
|
13
|
+
* task contract binding, controller fingerprint, validation result); any
|
|
14
|
+
* expiry, cross-session use, replay, or drift fails closed. Error codes reuse
|
|
15
|
+
* the existing confirmation lifecycle codes (CONFIRMATION_* /
|
|
16
|
+
* HUMAN_CONFIRMATION_REQUIRED / NOT_FOUND) because registry error codes are
|
|
17
|
+
* immutable in this task.
|
|
18
|
+
*/
|
|
19
|
+
import { randomBytes } from "node:crypto";
|
|
20
|
+
import { readFile } from "node:fs/promises";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { pathMatchesPattern } from "../../shared/git-progress.js";
|
|
23
|
+
import { readJsonIfExists, sha256Utf8, writeSecureJson, } from "./app-data.js";
|
|
24
|
+
import { hashDagBytes, } from "./dag-confirmation.js";
|
|
25
|
+
export const DEFAULT_EXECUTION_RECEIPT_TTL_MS = 30 * 60 * 1000;
|
|
26
|
+
function normalizePathEntry(entry) {
|
|
27
|
+
return entry.trim().replace(/\\/g, "/").replace(/^\.\//, "");
|
|
28
|
+
}
|
|
29
|
+
function isBroadWriteSetEntry(entry) {
|
|
30
|
+
const normalized = normalizePathEntry(entry);
|
|
31
|
+
if (!normalized || normalized === "." || normalized === "./")
|
|
32
|
+
return true;
|
|
33
|
+
if (normalized === "**")
|
|
34
|
+
return true;
|
|
35
|
+
// Placeholder marker (`REPLACE/WITH`) means the writeSet is not concrete yet.
|
|
36
|
+
return normalized.includes("REPLACE/WITH");
|
|
37
|
+
}
|
|
38
|
+
function isContainedInAllowed(candidate, allowedPaths) {
|
|
39
|
+
const normalized = normalizePathEntry(candidate);
|
|
40
|
+
if (!normalized)
|
|
41
|
+
return false;
|
|
42
|
+
return allowedPaths.some((allowed) => {
|
|
43
|
+
const pattern = normalizePathEntry(allowed);
|
|
44
|
+
if (!pattern || pattern === "." || pattern === "./")
|
|
45
|
+
return false;
|
|
46
|
+
return pathMatchesPattern(normalized, pattern);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function overlapsForbidden(candidate, forbiddenPaths) {
|
|
50
|
+
const normalized = normalizePathEntry(candidate);
|
|
51
|
+
if (!normalized)
|
|
52
|
+
return true;
|
|
53
|
+
return forbiddenPaths.some((forbidden) => {
|
|
54
|
+
const pattern = normalizePathEntry(forbidden);
|
|
55
|
+
if (!pattern)
|
|
56
|
+
return false;
|
|
57
|
+
return (pathMatchesPattern(normalized, pattern) ||
|
|
58
|
+
pathMatchesPattern(pattern, normalized));
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* G2 deterministic autonomous-execution assessment (design §5.2). Pure and
|
|
63
|
+
* exported so the dispatcher and unit tests share the exact same code path.
|
|
64
|
+
* Every condition must pass — any missing fact fails closed.
|
|
65
|
+
*/
|
|
66
|
+
export function assessAutonomousExecutionEligibility(input) {
|
|
67
|
+
const dangerous = input.dangerousMarkers ?? {};
|
|
68
|
+
if (dangerous.credentials || dangerous.destructive || dangerous.irreversible) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
reason: "DAG involves credentials, destructive operations, or irreversible migration; human authorization required",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (!input.hasStructuredVerification) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
reason: "DAG has no structured shell verification; autonomous execution not permitted",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (input.broadWriteSetRisk) {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
reason: "writeSet contains broad/placeholder entries; autonomous execution not permitted",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (input.forbiddenOverlapRisk) {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
reason: "writeSet overlaps forbidden paths; autonomous execution not permitted",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (!Array.isArray(input.allowedPaths) || !Array.isArray(input.forbiddenPaths)) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
reason: "task boundary (allowedPaths/forbiddenPaths) unavailable; autonomous execution not permitted",
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
for (const writer of input.writersWriteSets ?? []) {
|
|
99
|
+
if (!Array.isArray(writer.writeSet))
|
|
100
|
+
continue;
|
|
101
|
+
for (const entry of writer.writeSet) {
|
|
102
|
+
if (!isContainedInAllowed(entry, input.allowedPaths)) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
reason: `writer ${writer.nodeId} writeSet entry "${entry}" is outside allowedPaths`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (overlapsForbidden(entry, input.forbiddenPaths)) {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
reason: `writer ${writer.nodeId} writeSet entry "${entry}" overlaps forbiddenPaths`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { ok: true };
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Extract the deterministic G2 facts from a generated DAG spec (staged bytes).
|
|
120
|
+
* Mirrors the CLI review-packet semantics (generate-task-dag.ts) so the
|
|
121
|
+
* console never trusts client-supplied claims. Missing facts fail closed.
|
|
122
|
+
*/
|
|
123
|
+
export function deriveEligibilityFactsFromDagSpec(input) {
|
|
124
|
+
const spec = (input.dagSpec ?? {});
|
|
125
|
+
const tasks = Array.isArray(spec.tasks) ? spec.tasks : [];
|
|
126
|
+
const defaultWritePolicy = spec.defaults?.writePolicy;
|
|
127
|
+
const resolvePolicy = (task) => task.writePolicy ?? defaultWritePolicy ?? "none";
|
|
128
|
+
const stringList = (value) => Array.isArray(value)
|
|
129
|
+
? value.filter((v) => typeof v === "string")
|
|
130
|
+
: [];
|
|
131
|
+
const writers = tasks
|
|
132
|
+
.filter((task) => resolvePolicy(task) === "exclusive" &&
|
|
133
|
+
stringList(task.writeSet).length > 0)
|
|
134
|
+
.map((task) => ({
|
|
135
|
+
nodeId: task.id ?? "unknown",
|
|
136
|
+
writeSet: stringList(task.writeSet),
|
|
137
|
+
}));
|
|
138
|
+
const allWriteSetEntries = writers.flatMap((writer) => writer.writeSet);
|
|
139
|
+
const broadWriteSetRisk = allWriteSetEntries.some(isBroadWriteSetEntry);
|
|
140
|
+
const forbiddenOverlapRisk = tasks.some((task) => stringList(task.writeSet).some((entry) => [...stringList(task.forbiddenPaths), ...input.forbiddenPaths].some((forbidden) => pathMatchesPattern(entry, forbidden) ||
|
|
141
|
+
pathMatchesPattern(forbidden, entry))));
|
|
142
|
+
const hasStructuredVerification = tasks.some((task) => {
|
|
143
|
+
if (task.executor !== "shell" || !task.shell)
|
|
144
|
+
return false;
|
|
145
|
+
if (/verify|verification/i.test(task.id ?? ""))
|
|
146
|
+
return true;
|
|
147
|
+
const commands = stringList(task.shell.commands);
|
|
148
|
+
return commands.some((command) => /(vitest|npm run (lint|typecheck|test)|check-repo\.sh|loop-agent-standard-verify)/.test(command));
|
|
149
|
+
});
|
|
150
|
+
return {
|
|
151
|
+
writersWriteSets: writers,
|
|
152
|
+
allowedPaths: input.allowedPaths,
|
|
153
|
+
forbiddenPaths: input.forbiddenPaths,
|
|
154
|
+
broadWriteSetRisk,
|
|
155
|
+
forbiddenOverlapRisk,
|
|
156
|
+
hasStructuredVerification,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function receiptPath(appData, executionId) {
|
|
160
|
+
const dir = appData.dagReceipts ??
|
|
161
|
+
path.join(path.dirname(appData.confirmations), "dag-receipts");
|
|
162
|
+
return path.join(dir, `${executionId}.json`);
|
|
163
|
+
}
|
|
164
|
+
export function newExecutionId() {
|
|
165
|
+
return `exec_${randomBytes(12).toString("hex")}`;
|
|
166
|
+
}
|
|
167
|
+
export class DagExecutionReceiptStore {
|
|
168
|
+
appData;
|
|
169
|
+
constructor(appData) {
|
|
170
|
+
this.appData = appData;
|
|
171
|
+
}
|
|
172
|
+
async get(executionId) {
|
|
173
|
+
const rec = await readJsonIfExists(receiptPath(this.appData, executionId));
|
|
174
|
+
if (!rec)
|
|
175
|
+
return undefined;
|
|
176
|
+
return this.materializeExpiry(rec);
|
|
177
|
+
}
|
|
178
|
+
async materializeExpiry(rec, now = Date.now()) {
|
|
179
|
+
if (rec.state === "prepared" && Date.parse(rec.expiresAt) <= now) {
|
|
180
|
+
const expired = {
|
|
181
|
+
...rec,
|
|
182
|
+
state: "expired",
|
|
183
|
+
staleReason: rec.staleReason ?? "ttl exceeded (30min default)",
|
|
184
|
+
};
|
|
185
|
+
await writeSecureJson(receiptPath(this.appData, rec.executionId), expired);
|
|
186
|
+
return expired;
|
|
187
|
+
}
|
|
188
|
+
return rec;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Issue a single-use execution receipt. G2 eligibility is re-checked here
|
|
192
|
+
* (fail closed) in addition to the dispatcher's pre-check.
|
|
193
|
+
*/
|
|
194
|
+
async prepare(input) {
|
|
195
|
+
const assessment = assessAutonomousExecutionEligibility(input.eligibility);
|
|
196
|
+
if (!assessment.ok) {
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
code: "HUMAN_CONFIRMATION_REQUIRED",
|
|
200
|
+
reason: assessment.reason,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
if (!input.dagBytes ||
|
|
204
|
+
!input.dagBytesPath ||
|
|
205
|
+
!input.sourceBindingSha256 ||
|
|
206
|
+
!input.controllerFingerprint ||
|
|
207
|
+
!input.validationResultSha256 ||
|
|
208
|
+
!input.taskContractBinding?.taskId) {
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
code: "INVALID_INPUT",
|
|
212
|
+
reason: "missing server-derived DAG/binding facts for execution receipt",
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
const now = input.now ?? new Date();
|
|
216
|
+
const ttl = input.ttlMs ?? DEFAULT_EXECUTION_RECEIPT_TTL_MS;
|
|
217
|
+
const executionId = newExecutionId();
|
|
218
|
+
const rec = {
|
|
219
|
+
schemaVersion: 1,
|
|
220
|
+
executionId,
|
|
221
|
+
state: "prepared",
|
|
222
|
+
preparedAt: now.toISOString(),
|
|
223
|
+
expiresAt: new Date(now.getTime() + ttl).toISOString(),
|
|
224
|
+
operatorSessionId: input.operatorSessionId,
|
|
225
|
+
taskId: input.taskId,
|
|
226
|
+
dagHandle: input.dagHandle,
|
|
227
|
+
dagSha256: hashDagBytes(input.dagBytes),
|
|
228
|
+
dagBytesPath: input.dagBytesPath,
|
|
229
|
+
sourceBindingSha256: input.sourceBindingSha256,
|
|
230
|
+
taskContractBinding: input.taskContractBinding,
|
|
231
|
+
controllerFingerprint: input.controllerFingerprint,
|
|
232
|
+
validationResultSha256: input.validationResultSha256,
|
|
233
|
+
reviewPacket: input.reviewPacket,
|
|
234
|
+
};
|
|
235
|
+
await writeSecureJson(receiptPath(this.appData, executionId), rec);
|
|
236
|
+
return { ok: true, receipt: rec };
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Atomic single-use consume for runDag. Re-reads the staged DAG bytes and
|
|
240
|
+
* re-validates every binding; any drift/expiry/session/replay fails closed.
|
|
241
|
+
*/
|
|
242
|
+
async consume(input) {
|
|
243
|
+
const rec = await this.get(input.executionId);
|
|
244
|
+
if (!rec) {
|
|
245
|
+
return {
|
|
246
|
+
ok: false,
|
|
247
|
+
code: "NOT_FOUND",
|
|
248
|
+
message: `execution receipt not found: ${input.executionId}`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (rec.state === "expired") {
|
|
252
|
+
return {
|
|
253
|
+
ok: false,
|
|
254
|
+
code: "CONFIRMATION_EXPIRED",
|
|
255
|
+
message: "execution receipt expired",
|
|
256
|
+
receipt: rec,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
if (rec.state === "consumed") {
|
|
260
|
+
return {
|
|
261
|
+
ok: false,
|
|
262
|
+
code: "CONFIRMATION_CONSUMED",
|
|
263
|
+
message: "execution receipt already consumed (single-use)",
|
|
264
|
+
receipt: rec,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
if (rec.state === "stale") {
|
|
268
|
+
return {
|
|
269
|
+
ok: false,
|
|
270
|
+
code: "CONFIRMATION_STALE",
|
|
271
|
+
message: rec.staleReason ?? "execution receipt is stale",
|
|
272
|
+
receipt: rec,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
if (rec.state !== "prepared") {
|
|
276
|
+
return {
|
|
277
|
+
ok: false,
|
|
278
|
+
code: "HUMAN_CONFIRMATION_REQUIRED",
|
|
279
|
+
message: `execution receipt is ${rec.state}; re-prepare required`,
|
|
280
|
+
receipt: rec,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (rec.operatorSessionId !== input.operatorSessionId) {
|
|
284
|
+
return {
|
|
285
|
+
ok: false,
|
|
286
|
+
code: "HUMAN_CONFIRMATION_REQUIRED",
|
|
287
|
+
message: "session mismatch; execution receipt cannot be consumed across operator sessions",
|
|
288
|
+
receipt: rec,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
// Re-read staged DAG bytes and re-hash (fail closed on any drift).
|
|
292
|
+
let dagBytes;
|
|
293
|
+
try {
|
|
294
|
+
dagBytes = await readFile(rec.dagBytesPath, "utf8");
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
const stale = await this.markStale(rec.executionId, `cannot re-read staged DAG bytes: ${error instanceof Error ? error.message : String(error)}`);
|
|
298
|
+
return {
|
|
299
|
+
ok: false,
|
|
300
|
+
code: "CONFIRMATION_STALE",
|
|
301
|
+
message: "cannot re-read staged DAG bytes",
|
|
302
|
+
receipt: stale,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
const dagSha256 = hashDagBytes(dagBytes);
|
|
306
|
+
if (dagSha256 !== rec.dagSha256) {
|
|
307
|
+
const stale = await this.markStale(rec.executionId, "staged DAG bytes drift (hash mismatch on consume)");
|
|
308
|
+
return {
|
|
309
|
+
ok: false,
|
|
310
|
+
code: "CONFIRMATION_STALE",
|
|
311
|
+
message: "staged DAG bytes drift (hash mismatch on consume)",
|
|
312
|
+
receipt: stale,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
const drift = detectReceiptBindingDrift(rec, {
|
|
316
|
+
sourceBindingSha256: input.sourceBindingSha256,
|
|
317
|
+
taskContractBinding: input.taskContractBinding,
|
|
318
|
+
controllerFingerprint: input.controllerFingerprint,
|
|
319
|
+
validationResultSha256: input.validationResultSha256,
|
|
320
|
+
});
|
|
321
|
+
if (drift) {
|
|
322
|
+
const stale = await this.markStale(rec.executionId, drift);
|
|
323
|
+
return {
|
|
324
|
+
ok: false,
|
|
325
|
+
code: "CONFIRMATION_STALE",
|
|
326
|
+
message: drift,
|
|
327
|
+
receipt: stale,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const now = input.now ?? new Date();
|
|
331
|
+
const consumed = {
|
|
332
|
+
...rec,
|
|
333
|
+
state: "consumed",
|
|
334
|
+
consumedAt: now.toISOString(),
|
|
335
|
+
};
|
|
336
|
+
await writeSecureJson(receiptPath(this.appData, rec.executionId), consumed);
|
|
337
|
+
return { ok: true, receipt: consumed };
|
|
338
|
+
}
|
|
339
|
+
/** Record the operation id that consumed this receipt (post-accept). */
|
|
340
|
+
async markDispatched(executionId, operationId) {
|
|
341
|
+
const rec = await this.get(executionId);
|
|
342
|
+
if (!rec || rec.state !== "consumed")
|
|
343
|
+
return rec;
|
|
344
|
+
const dispatched = {
|
|
345
|
+
...rec,
|
|
346
|
+
consumedOperationId: operationId,
|
|
347
|
+
};
|
|
348
|
+
await writeSecureJson(receiptPath(this.appData, executionId), dispatched);
|
|
349
|
+
return dispatched;
|
|
350
|
+
}
|
|
351
|
+
async markStale(executionId, reason) {
|
|
352
|
+
const rec = await this.get(executionId);
|
|
353
|
+
if (!rec) {
|
|
354
|
+
throw new Error(`execution receipt not found: ${executionId}`);
|
|
355
|
+
}
|
|
356
|
+
const stale = {
|
|
357
|
+
...rec,
|
|
358
|
+
state: "stale",
|
|
359
|
+
staleReason: reason,
|
|
360
|
+
};
|
|
361
|
+
await writeSecureJson(receiptPath(this.appData, executionId), stale);
|
|
362
|
+
return stale;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function detectReceiptBindingDrift(rec, current) {
|
|
366
|
+
if (current.sourceBindingSha256 !== rec.sourceBindingSha256) {
|
|
367
|
+
return "sourceBindingSha256 binding drift";
|
|
368
|
+
}
|
|
369
|
+
if (current.controllerFingerprint !== rec.controllerFingerprint) {
|
|
370
|
+
return "controllerFingerprint binding drift";
|
|
371
|
+
}
|
|
372
|
+
if (current.validationResultSha256 !== rec.validationResultSha256) {
|
|
373
|
+
return "validationResultSha256 binding drift";
|
|
374
|
+
}
|
|
375
|
+
const a = sha256Utf8(JSON.stringify(rec.taskContractBinding));
|
|
376
|
+
const b = sha256Utf8(JSON.stringify(current.taskContractBinding));
|
|
377
|
+
if (a !== b)
|
|
378
|
+
return "taskContractBinding drift";
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|