pi-subagents 0.60.0 → 0.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/docs/agents.md +2 -2
- package/docs/configuration.md +9 -5
- package/docs/extension-api.md +14 -7
- package/docs/models.md +1 -1
- package/docs/observability.md +1 -1
- package/docs/tool-reference.md +12 -3
- package/docs/workflows.md +14 -13
- package/install.mjs +2 -1
- package/package.json +1 -1
- package/skills/pi-subagents/SKILL.md +6 -4
- package/skills/pi-subagents/references/constraints-and-recipes.md +1 -1
- package/skills/pi-subagents/references/execution-controls.md +42 -11
- package/skills/pi-subagents/references/multi-lane-orchestration.md +1 -1
- package/skills/pi-subagents/references/prompting-and-roles.md +4 -4
- package/skills/pi-subagents/references/review-and-validation.md +1 -1
- package/src/agents/agent-management.ts +102 -63
- package/src/agents/agents.ts +527 -221
- package/src/api/background-work.ts +7 -2
- package/src/api/external-runs.ts +67 -4
- package/src/api/preflight.ts +13 -8
- package/src/api/shared-types.ts +1 -0
- package/src/extension/index.ts +7 -4
- package/src/extension/public-execution.ts +47 -4
- package/src/extension/rpc.ts +62 -4
- package/src/extension/schemas.ts +11 -7
- package/src/extension/tool-description.ts +14 -18
- package/src/runs/background/async-execution.ts +51 -35
- package/src/runs/background/async-job-tracker.ts +62 -3
- package/src/runs/background/async-resume.ts +3 -1
- package/src/runs/background/async-status.ts +59 -9
- package/src/runs/background/auto-drain.ts +1 -1
- package/src/runs/background/fleet-view.ts +1 -1
- package/src/runs/background/result-watcher.ts +1 -1
- package/src/runs/background/resume-guidance.ts +1 -1
- package/src/runs/background/run-status.ts +2 -2
- package/src/runs/background/subagent-runner.ts +1 -2
- package/src/runs/background/subagent-wait.ts +20 -21
- package/src/runs/background/wait-completions.ts +1 -1
- package/src/runs/background/wait-tool.ts +24 -18
- package/src/runs/foreground/execution.ts +61 -2
- package/src/runs/foreground/subagent-executor.ts +178 -58
- package/src/runs/shared/acceptance.ts +43 -18
- package/src/runs/shared/host-step-status.ts +1 -0
- package/src/runs/shared/model-fallback.ts +61 -17
- package/src/runs/shared/permissions.ts +1 -1
- package/src/runs/shared/tool-timeout.ts +1 -1
- package/src/runs/shared/workflow-graph.ts +3 -2
- package/src/shared/types.ts +40 -3
- package/src/shared/workflow-child-permit.ts +91 -0
- package/src/slash/prompt-template-bridge.ts +37 -1
- package/src/slash/slash-commands.ts +18 -26
- package/src/tui/render.ts +95 -52
- package/src/workflows/scripted-workflow.ts +153 -4
- package/src/workflows/workflow-child-summary.ts +1 -1
- package/src/workflows/workflow-receipt.ts +41 -4
- package/src/workflows/workflow-resources.ts +150 -0
|
@@ -4,7 +4,8 @@ import { dirname, resolve as resolvePath } from "node:path";
|
|
|
4
4
|
import { Worker } from "node:worker_threads";
|
|
5
5
|
import { DEFAULT_GLOBAL_CONCURRENCY_LIMIT, Semaphore } from "../runs/shared/parallel-utils.ts";
|
|
6
6
|
import { HOST_STEP_MAX_COUNT } from "../runs/shared/host-step-status.ts";
|
|
7
|
-
import
|
|
7
|
+
import { classifyTaskMutationIntent } from "../runs/shared/task-intent.ts";
|
|
8
|
+
import type { AcceptanceRecoveryMetadata, HostStepNodeV1, SingleResult } from "../shared/types.ts";
|
|
8
9
|
import { normalizeWorkflowHostCommandParams, type WorkflowHostCommandParams, type WorkflowHostCommandResult } from "./host-command.ts";
|
|
9
10
|
|
|
10
11
|
const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
@@ -604,7 +605,7 @@ function validateRunCall(key, params, label, fingerprints) {
|
|
|
604
605
|
if (typeof key !== "string" || !runKeyPattern.test(key)) throw new Error(label + " has an invalid key.");
|
|
605
606
|
if (hostKeys.has(key)) throw new Error("Workflow key '" + key + "' is already used by runs.host.");
|
|
606
607
|
if (!params || typeof params !== "object" || Array.isArray(params)) throw new Error(label + " requires a params object.");
|
|
607
|
-
if (Object.prototype.hasOwnProperty.call(params, "action") || Object.prototype.hasOwnProperty.call(params, "workflowScript") || Object.prototype.hasOwnProperty.call(params, "tasks") || Object.prototype.hasOwnProperty.call(params, "chain") || Object.prototype.hasOwnProperty.call(params, "parallel") || Object.prototype.hasOwnProperty.call(params, "concurrency") || Object.prototype.hasOwnProperty.call(params, "chainDir")) {
|
|
608
|
+
if (Object.prototype.hasOwnProperty.call(params, "action") || Object.prototype.hasOwnProperty.call(params, "workflowScript") || Object.prototype.hasOwnProperty.call(params, "globalConcurrencyLimit") || Object.prototype.hasOwnProperty.call(params, "maxSubagentSpawnsPerRun") || Object.prototype.hasOwnProperty.call(params, "tasks") || Object.prototype.hasOwnProperty.call(params, "chain") || Object.prototype.hasOwnProperty.call(params, "parallel") || Object.prototype.hasOwnProperty.call(params, "concurrency") || Object.prototype.hasOwnProperty.call(params, "chainDir")) {
|
|
608
609
|
const hint = label === "runs.run" ? "; use runs.all(...) and JavaScript control flow for orchestration." : ".";
|
|
609
610
|
throw new Error(label + " accepts one child via { agent, task } and execution controls only" + hint);
|
|
610
611
|
}
|
|
@@ -975,6 +976,7 @@ export interface WorkflowScriptChildResult {
|
|
|
975
976
|
requestedContext?: "fresh" | "fork";
|
|
976
977
|
resolvedContext?: "fresh" | "fork" | "mixed";
|
|
977
978
|
outputReference?: string;
|
|
979
|
+
recovery?: AcceptanceRecoveryMetadata;
|
|
978
980
|
outputPathMapping?: { requestedPath: string; savedPath: string };
|
|
979
981
|
externalAdapter?: import("../shared/types.ts").ExternalCliReceiptMetadata;
|
|
980
982
|
resumability?: { state: "resumable" } | { state: "not-resumable"; reason: string };
|
|
@@ -1107,6 +1109,138 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
1107
1109
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
1108
1110
|
}
|
|
1109
1111
|
|
|
1112
|
+
function isAcceptanceMetadataRecovery(result: WorkflowScriptChildResult): boolean {
|
|
1113
|
+
return !result.ok
|
|
1114
|
+
&& result.recovery?.status === "available-for-review"
|
|
1115
|
+
&& result.recovery.reason === "acceptance-metadata-rejected";
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const RECOVERY_REVIEW_MUTATION_VERB_PATTERN = /\b(?:add(?:ing)?|append(?:ing)?|apply(?:ing)?|cherry[ -]pick(?:ing)?|change|changing|clean(?:ing)?|commit(?:ting)?|cop(?:y|ying)|create|creating|delete|deleting|edit(?:ing)?|fix(?:ing)?|implement(?:ing)?|insert(?:ing)?|make|making|merge|merging|mov(?:e|ing)|modify(?:ing)?|mutate|mutating|open(?:ing)?|patch(?:ing)?|prepend(?:ing)?|push(?:ing)?|rebase|rebasing|refactor(?:ing)?|remove|removing|rename|renaming|replace|replacing|revert(?:ing)?|revise|revising|rewrite|rewriting|sav(?:e|ing)|stag(?:e|ing)|stash(?:ing)?|tag(?:ging)?|touch(?:ing)?|update|updating|write|writing)\b/i;
|
|
1119
|
+
const RECOVERY_REVIEW_READ_ONLY_PATTERN = /\b(?:read[- ]only|review only|only return findings|return findings only|suggest fixes only|without\s+(?:editing|modifying|changing|writing|touching)|do not\s+(?:edit|modify|change|write|touch)|don't\s+(?:edit|modify|change|write|touch)|must not\s+(?:edit|modify|change|write|touch))\b/i;
|
|
1120
|
+
const RECOVERY_REVIEW_NO_MUTATION_CLAUSE_PATTERN = /\b(?:do not|don't|must not)\s+(?:edit|modify|change|write|touch)(?:\s+files?)?(?:\s*,\s*(?:commit|push|comment|merge|launch(?:\s+subagents?)?)(?=\s*(?:,|\bor\b|[.;!?\n)]|$)))*(?:\s*,?\s*or\s+(?:commit|push|comment|merge|launch(?:\s+subagents?)?)(?=\s*(?:[.;!?\n)]|$)))?/gi;
|
|
1121
|
+
const RECOVERY_REVIEW_DELIVERABLE_PATTERN = /\b(?:compose|create|draft|prepare|produce|write)\s+(?:(?:a|an|the|your)\s+)?(?:findings?|review|report|summary|analysis|recommendations?)(?:\s+(?:to|at|in)\s+\S+)?/gi;
|
|
1122
|
+
const RECOVERY_REVIEW_CONTEXT_OBJECT_PATTERN = /\breview\s+(?:(?:the|this|that|saved)\s+)?(?:patch|diff|changes?|implementation|report)\b/gi;
|
|
1123
|
+
const RECOVERY_REVIEW_MUTATION_NOUN_CONTEXT_PATTERN = /\b(?:later\s+real\s+)?update\s+imperatives?\b/gi;
|
|
1124
|
+
const RECOVERY_REVIEW_PRIOR_FIX_CONTEXT_PATTERN = /\bthe\s+prior\s+fix\s+keeps\b/gi;
|
|
1125
|
+
const RECOVERY_REVIEW_DETECTION_CONTEXT_PATTERN = /\b(?:mutation\s+detection\s+now\s+includes\s+move\/rename\/copy\s+file\s+mutation\s+imperatives|delegation\s+detection\s+now\s+blocks\s+get\/let\/have\/tell\/ask\s+follow-up\s+forms)(?=[,.;!?\n]|\b(?:and|but|then|however|nevertheless|nonetheless|yet)\b|$)/gi;
|
|
1126
|
+
const RECOVERY_REVIEW_PATTERN_CHANGE_CONTEXT_PATTERN = /\bRECOVERY_REVIEW_MUTATION_VERB_PATTERN\s+now\s+includes\s+append,\s+prepend,\s+and\s+sav(?:e|ing)\b(?=\s*(?:[,.;!?\n)]|$))/gi;
|
|
1127
|
+
const RECOVERY_REVIEW_GIT_PATTERN_CHANGE_CONTEXT_PATTERN = /\bfixed\s+mutating\s+git\s+follow-up\s+bypasses\b|\b(?:added|adding)\s+[a-z][a-z-]*(?:\/[a-z][a-z-]*)*(?:(?:\s*,\s*(?:and\s+)?|\s+and\s+)[a-z][a-z-]*(?:\/[a-z][a-z-]*)*)*\s+to\s+the\s+(?:mutation\s+imperative|mutating\s+git\s+command)\s+pattern\b|\band\s+[a-z][a-z-]*(?:\/[a-z][a-z-]*)*(?:(?:\s*,\s*(?:and\s+)?|\s+and\s+)[a-z][a-z-]*(?:\/[a-z][a-z-]*)*)*\s+to\s+the\s+mutating\s+git\s+command\s+pattern\b/gi;
|
|
1128
|
+
const RECOVERY_REVIEW_VALIDATION_EVIDENCE_PATTERN = /\bvalidation(?:\s+after\s+fix)?\s*:/gi;
|
|
1129
|
+
const RECOVERY_REVIEW_CONTRACT_PROHIBITION_PATTERN = /\b(?:do not|don't|must not)\s+(?:mutate\s+durable\s+state|launch\s+(?:mutating|destructive|mutating\/destructive)\s+work)(?:\s+or\s+(?:mutate\s+durable\s+state|launch\s+(?:mutating|destructive|mutating\/destructive)\s+work))*/gi;
|
|
1130
|
+
const RECOVERY_REVIEW_ONLY_REVIEW_CONTRACT_PATTERN = /\bmay\s+only\s+launch\s+explicit\s+read-only\s+review\s+children\s+with\s+acceptance:false\b/gi;
|
|
1131
|
+
const RECOVERY_REVIEW_BLOCKED_CONTEXT_FRAGMENT_PATTERN = /\bstate\.set, runs\.host, runs\.steer, ordinary\/mutating children, and destructive command wording are blocked(?=[,.;!?\n)\]}]|$)/gi;
|
|
1132
|
+
const RECOVERY_REVIEW_REGRESSION_EVIDENCE_PATTERN = /\b(?:existing regressions cover plain rm and git clean\/reset\/restore|prior regressions covering rm\/git clean as evidence only)(?=[,.;!?\n)\]}]|$)/gi;
|
|
1133
|
+
const RECOVERY_REVIEW_BLOCKED_QUOTED_EXAMPLE_PATTERN = /(?:`[^`\n]+`|'[^'\n]+'|"[^"\n]+")(?:(?:\s*,\s*(?:and\s+)?|\s+and\s+)(?:`[^`\n]+`|'[^'\n]+'|"[^"\n]+"))*\s+(?:is|are|remains?)\s+(?:blocked|(?:an?\s+)?blocked\s+examples?)(?=[,.;!?\n)\]}]|$)/gi;
|
|
1134
|
+
const RECOVERY_REVIEW_CONTEXT_QUOTED_EXAMPLE_PATTERN = /\b(?:examples?|phrasing|forms|variants):\s*(?:`[^`\n]+`|'[^'\n]+'|"[^"\n]+")(?:(?:\s*,\s*(?:and\s+)?|\s+and\s+)(?:`[^`\n]+`|'[^'\n]+'|"[^"\n]+"))*/gi;
|
|
1135
|
+
const RECOVERY_REVIEW_LISTED_BLOCKED_EXAMPLE_PATTERN = /(?:^|[.;!?\n]\s*)blocked\s+examples:\s*(?:\r?\n[ \t]*(?:[-*]|\d+[.)])\s+[^\r\n]+)+/gim;
|
|
1136
|
+
const RECOVERY_REVIEW_BLOCKS_QUOTED_EXAMPLE_PATTERN = /\b(?:this\s+)?blocks?\s+examples?\s+like\s+(?:`[^`\n]+`|'[^'\n]+'|"[^"\n]+")(?:(?:\s*,\s*(?:and\s+)?|\s+and\s+)(?:`[^`\n]+`|'[^'\n]+'|"[^"\n]+"))*/gi;
|
|
1137
|
+
const RECOVERY_REVIEW_EXAMPLES_LIKE_BLOCKED_PATTERN = /\bexamples?\s+like\s+(?:`[^`\n]+`|'[^'\n]+'|"[^"\n]+")(?:(?:\s*,\s*(?:and\s+)?|\s+and\s+)(?:`[^`\n]+`|'[^'\n]+'|"[^"\n]+"))*\s+(?:is|are|remains?)\s+blocked\b/gi;
|
|
1138
|
+
const RECOVERY_REVIEW_QUOTED_VISIBLE_BLOCKED_PATTERN = /(?:\b(?:(?:the\s+)?examples?|commands?\s+hidden\s+in)\s+|(?:^|[\s([{]))(?:`[^`\n]+`|'[^'\n]+'|"[^"\n]+")(?:(?:\s*,\s*(?:and\s+|or\s+)?|\s+(?:and|or)\s+)(?:`[^`\n]+`|'[^'\n]+'|"[^"\n]+"))*\s+(?:(?:is|are|remains?)\s+)?visible\s+and\s+blocked\b/gi;
|
|
1139
|
+
const RECOVERY_REVIEW_PROMPTS_LIKE_BLOCKED_PATTERN = /\bdescribed\s+prompts?\s+like\s+Run\s+git\s+rebase\s+main,\s+git\s+rebase\s+main,\s+Run\s+git\s+cherry-pick\s+abc123,\s+cherry-pick\s+abc123,\s+and\s+Stage\s+the\s+changed\s+files\s+as\s+blocked\b/gi;
|
|
1140
|
+
const RECOVERY_REVIEW_GIT_MUTATIONS_BROADER_CONTEXT_PATTERN = /\bpositive\s+git\s+mutations\s+are\s+broader:\s*git\s+branch\s+-D\s+old,\s+git\s+tag\s+-d\s+v1\.0,\s+git\s+stash,\s+git\s+revert\s+abc123,\s+and\s+natural\s+cherry\s+pick\s+abc123\s+now\s+trips?\s+the\s+recovery\s+barrier\b/gi;
|
|
1141
|
+
const RECOVERY_REVIEW_GIT_COVERAGE_CONTEXT_PATTERN = /\bbroadened\s+positive\s+git\s+mutation\s+coverage\s+for\s+natural\s+`cherry\s+pick`,\s+`revert`,\s+`stash`,\s+`tag`,\s+plus\s+git\s+`branch\|revert\|stash\|tag`/gi;
|
|
1142
|
+
const RECOVERY_REVIEW_REGRESSION_QUOTED_EXAMPLE_PATTERN = /\b(?:added\s+)?exact\s+regressions?\s+for\s+(?:`[^`\n]+`|'[^'\n]+'|"[^"\n]+")(?:(?:\s*,\s*(?:and\s+)?|\s+and\s+)(?:`[^`\n]+`|'[^'\n]+'|"[^"\n]+"))*/gi;
|
|
1143
|
+
const RECOVERY_REVIEW_REGRESSION_ANAPHORIC_EXAMPLE_PATTERN = /\b(?:added\s+)?exact\s+regressions?\s+for\s+(?:do|run|execute|perform|apply)\s+(?:it|that|this|(?:the\s+)?(?:(?:previous(?:ly)?|prior|above|quoted|blocked)\s+){0,4}(?:command|example|operation|action|phrase|instruction|request))(?:[\s,]+(?:now|still|again|really|actually|immediately)){0,3}[\s,]+anyway(?:(?:\s*,\s*(?:and\s+)?|\s+and\s+)(?:do|run|execute|perform|apply)\s+(?:it|that|this|(?:the\s+)?(?:(?:previous(?:ly)?|prior|above|quoted|blocked)\s+){0,4}(?:command|example|operation|action|phrase|instruction|request))(?:[\s,]+(?:now|still|again|really|actually|immediately)){0,3}[\s,]+anyway)*/gi;
|
|
1144
|
+
const RECOVERY_REVIEW_REGRESSION_FOLLOWED_BY_ANAPHORIC_PATTERN = /\badded\s+(?:exact\s+)?regressions?\s+for\s+quoted\s+rm\s+remaining\s+blocked\s+followed\s+by\s+execute\s+the\s+previous\s+command\b(?=\s*(?:[,.;!?\n)]|\bwhile\b|$))/gi;
|
|
1145
|
+
const RECOVERY_REVIEW_REGRESSION_FOLLOWED_BY_NAMED_RM_PATTERN = /\b(?:added\s+)?(?:exact\s+)?regressions?\s+for\s+quoted\s+rm\s+remaining\s+blocked\s+followed\s+by\s+(?:do|run|execute|perform|apply)\s+(?:it|that|this|(?:the\s+)?(?:(?:previous(?:ly)?|prior|above|quoted|blocked)\s+){0,4}(?:command|example|operation|action|phrase|instruction|request))(?:[\s,]+(?:now|still|again|really|actually|immediately)){0,3}(?:[\s,]+anyway)?(?=\s*(?:[,.;!?\n)]|\bwhile\b|$))/gi;
|
|
1146
|
+
const RECOVERY_REVIEW_BLOCKED_LIVE_VARIANT_CONTEXT_PATTERN = /\b(?:while\s+)?keeping\s+live-command\s+variants\s+such\s+as\s+followed\s+by\s+execute\s+the\s+previous\s+command\s+then\s+update\s+tests\s+blocked\b(?=\s*(?:[,.;!?\n)]|$))/gi;
|
|
1147
|
+
const RECOVERY_REVIEW_ANAPHORIC_REFERENCES_CONTEXT_PATTERN = /\bdirect\s+anaphoric\s+references\s+now\s+include\s+numeric\s+and\s+word\s+ordinals\s+through\s+tenth\s+plus\s+one,\s+so\s+(?:do|run|execute|perform|apply)\s+(?:it|that|this|(?:the\s+)?(?:(?:\d+(?:st|nd|rd|th)|first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth|last|next|previous(?:ly)?|prior|above|quoted|blocked)\s+){0,4}(?:command|example|operation|action|phrase|instruction|request|one))(?:[\s,]+(?:right|now|still|again|really|actually|immediately)){0,4}[\s,]+anyway(?:(?:\s*,\s*(?:and\s+)?|\s+and\s+)(?:do|run|execute|perform|apply)\s+(?:it|that|this|(?:the\s+)?(?:(?:\d+(?:st|nd|rd|th)|first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth|last|next|previous(?:ly)?|prior|above|quoted|blocked)\s+){0,4}(?:command|example|operation|action|phrase|instruction|request|one))(?:[\s,]+(?:right|now|still|again|really|actually|immediately)){0,4}[\s,]+anyway)*\s+(?:is|are|remains?)\s+blocked\b(?:\s+after\s+quoted\s+destructive\s+examples\s+are\s+scrubbed)?/gi;
|
|
1148
|
+
const RECOVERY_REVIEW_NO_ANAPHORIC_MUTATION_CLAUSE_PATTERN = /\b(?:do not|don't|must not)\s+(?:do|run|execute|perform|apply)\s+(?:it|that|this|(?:the\s+)?(?:(?:\d+(?:st|nd|rd|th)|first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth|last|next|previous(?:ly)?|prior|above|quoted|blocked)\s+){0,4}(?:command|example|operation|action|phrase|instruction|request|one))(?:(?!\b(?:and|but|then|however|nevertheless|nonetheless|yet)\b)[^,.;!?\n—–-]){0,80}/gi;
|
|
1149
|
+
const RECOVERY_REVIEW_NO_DELEGATION_CLAUSE_PATTERN = /\b(?:do not|don't|must not)\s+(?:(?:launch|start|spawn|run)\b(?:(?!\b(?:and|but|then|however|nevertheless|nonetheless|yet)\b)[^,.;!?\n])*(?:workers?|reviewers?|agents?|subagents?|children|child|runs?)|(?:get|let|request|hand\s+off|assign|use|have|tell)\b(?:(?!\b(?:and|but|then|however|nevertheless|nonetheless|yet)\b)[^,.;!?\n])*(?:workers?|reviewers?|agents?|subagents?|children|child)|(?:get|let|have|tell|request)\b(?:(?!\b(?:and|but|then|however|nevertheless|nonetheless|yet)\b)[^,.;!?\n])*(?:review|implementation|fix(?:es)?|changes?|follow-up)\b(?:(?!\b(?:and|but|then|however|nevertheless|nonetheless|yet)\b)[^,.;!?\n])*\b(?:from|with|via|by)\b(?:(?!\b(?:and|but|then|however|nevertheless|nonetheless|yet)\b)[^,.;!?\n])*(?:workers?|reviewers?|agents?|subagents?|children|child)|ask\b(?:(?!\b(?:and|but|then|however|nevertheless|nonetheless|yet)\b)[^,.;!?\n])*(?:(?:workers?|reviewers?|agents?|subagents?|children|child)\b(?:(?!\b(?:and|but|then|however|nevertheless|nonetheless|yet)\b)[^,.;!?\n])*\b(?:continue|implement|review|fix|edit|write|modify|change|patch|update|delete|remove|create|follow-up)|(?:review|implementation|fix(?:es)?|changes?|follow-up)\b(?:(?!\b(?:and|but|then|however|nevertheless|nonetheless|yet)\b)[^,.;!?\n])*\b(?:from|via)\b(?:(?!\b(?:and|but|then|however|nevertheless|nonetheless|yet)\b)[^,.;!?\n])*(?:workers?|reviewers?|agents?|subagents?|children|child)))\b/gi;
|
|
1150
|
+
const RECOVERY_REVIEW_DELEGATION_PATTERN = /\b(?:launch|start|spawn|run)\b[^,.;!?\n]*(?:workers?|reviewers?|agents?|subagents?|children|child|runs?)\b|\b(?:delegate|hand\s+off|assign)\s+(?:remediation|implementation(?:\s+follow-up)?|changes?|fix(?:es)?|follow-up)\s+to\s+(?:(?:a|an|the)\s+)?(?:workers?|reviewers?|agents?|subagents?|children|child)\b|\b(?:get|let|have|tell|request)\b[^,.;!?\n]*(?:workers?|reviewers?|agents?|subagents?|children|child)\b[^,.;!?\n]*\b(?:continue|implement|review|fix|edit|write|modify|change|patch|update|delete|remove|create|follow-up)\b|\b(?:get|let|have|tell|request)\b[^,.;!?\n]*(?:review|implementation|fix(?:es)?|changes?|follow-up)\b[^,.;!?\n]*\b(?:from|with|via|by)\b[^,.;!?\n]*(?:workers?|reviewers?|agents?|subagents?|children|child)\b|\bask\b[^,.;!?\n]*(?:(?:workers?|reviewers?|agents?|subagents?|children|child)\b[^,.;!?\n]*\b(?:continue|implement|review|fix|edit|write|modify|change|patch|update|delete|remove|create|follow-up)|(?:review|implementation|fix(?:es)?|changes?|follow-up)\b[^,.;!?\n]*\b(?:from|via)\b[^,.;!?\n]*(?:workers?|reviewers?|agents?|subagents?|children|child))\b|\buse\b[^,.;!?\n]*(?:workers?|reviewers?|agents?|subagents?|children|child|runs?)\s+(?:for|to)\s+(?:implementation|follow-up|remediation|fix|edit|write|modify|change|patch|update|delete|remove|create)\b/i;
|
|
1151
|
+
const RECOVERY_REVIEW_ANAPHORIC_MUTATION_PATTERN = /\b(?:do|run|execute|perform|apply)\s+(?:it|that|this|(?:the\s+)?(?:(?:\d+(?:st|nd|rd|th)|first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth|last|next|previous(?:ly)?|prior|above|quoted|blocked)\s+){0,4}(?:command|example|operation|action|phrase|instruction|request|one))(?!(?:\s+(?:(?:now|still|also|already)\s+)*(?:is|are|remains?)\s+blocked\b))(?:(?:[\s,]+\w+){0,4}[\s,]+anyway|(?:(?!\b(?:and|but|then|however|nevertheless|nonetheless|yet)\b)[^,.;!?\n]){0,80})(?=\s*(?:[,.;!?\n)]|\b(?:and|but|then|however|nevertheless|nonetheless|yet)\b|$))/i;
|
|
1152
|
+
const RECOVERY_REVIEW_DESTRUCTIVE_COMMAND_PATTERN = /(?:^|[\s;,.`'"([{])(?:\S*\/)?(?:rm|rmdir|unlink|truncate|mv|cp|chmod|chown)\b|\bgit\b(?:\s+(?:-[A-Za-z](?:\s+(?:"[^"\n]*"|'[^'\n]*'|\S+))?|--(?:git-dir|work-tree|namespace|exec-path|config-env)(?:=(?:"[^"\n]*"|'[^'\n]*'|\S+)|\s+(?:"[^"\n]*"|'[^'\n]*'|\S+))|--[A-Za-z0-9-]+(?:=(?:"[^"\n]*"|'[^'\n]*'|\S+))?))*\s+(?:add|branch|cherry-pick|clean|commit|merge|rebase|reset|restore|revert|stash|tag|checkout|switch)\b/i;
|
|
1153
|
+
const RECOVERY_REVIEW_DASH_LIVE_ACTION_PATTERN = /(?:[—–]|--|\s-\s|:|\s\/\s)\s*(?:then\s+)?(?:(?:add|append|apply|change|cherry[ -]pick|clean|commit|copy|create|delete|edit|fix|implement|insert|make|merge|move|modify|mutate|open|patch|prepend|push|rebase|refactor|remove|rename|replace|revert|revise|rewrite|save|stage|stash|tag|touch|update|write)\b|(?:launch|start|spawn|run)\b[^,.;!?\n]*(?:workers?|reviewers?|agents?|subagents?|children|child|runs?)\b|(?:\S*\/)?(?:rm|rmdir|unlink|truncate|mv|cp|chmod|chown)\b|git\b[^,.;!?\n]*\b(?:add|branch|cherry-pick|clean|commit|merge|rebase|reset|restore|revert|stash|tag|checkout|switch)\b)/i;
|
|
1154
|
+
|
|
1155
|
+
function isExplicitReadOnlyRecoveryReview(params: Record<string, unknown>): boolean {
|
|
1156
|
+
const agent = typeof params.agent === "string" ? params.agent.trim() : "";
|
|
1157
|
+
const task = typeof params.task === "string" ? params.task.trim() : "";
|
|
1158
|
+
const taskDestructiveCommandText = task
|
|
1159
|
+
.replace(RECOVERY_REVIEW_BLOCKED_CONTEXT_FRAGMENT_PATTERN, " ")
|
|
1160
|
+
.replace(RECOVERY_REVIEW_REGRESSION_EVIDENCE_PATTERN, " ")
|
|
1161
|
+
.replace(RECOVERY_REVIEW_BLOCKED_QUOTED_EXAMPLE_PATTERN, " ")
|
|
1162
|
+
.replace(RECOVERY_REVIEW_CONTEXT_QUOTED_EXAMPLE_PATTERN, " ")
|
|
1163
|
+
.replace(RECOVERY_REVIEW_LISTED_BLOCKED_EXAMPLE_PATTERN, " ")
|
|
1164
|
+
.replace(RECOVERY_REVIEW_BLOCKS_QUOTED_EXAMPLE_PATTERN, " ")
|
|
1165
|
+
.replace(RECOVERY_REVIEW_EXAMPLES_LIKE_BLOCKED_PATTERN, " ")
|
|
1166
|
+
.replace(RECOVERY_REVIEW_QUOTED_VISIBLE_BLOCKED_PATTERN, " ")
|
|
1167
|
+
.replace(RECOVERY_REVIEW_PROMPTS_LIKE_BLOCKED_PATTERN, " ")
|
|
1168
|
+
.replace(RECOVERY_REVIEW_GIT_MUTATIONS_BROADER_CONTEXT_PATTERN, " ")
|
|
1169
|
+
.replace(RECOVERY_REVIEW_GIT_COVERAGE_CONTEXT_PATTERN, " ")
|
|
1170
|
+
.replace(RECOVERY_REVIEW_REGRESSION_FOLLOWED_BY_NAMED_RM_PATTERN, " ");
|
|
1171
|
+
const taskDashLiveActionText = task
|
|
1172
|
+
.replace(RECOVERY_REVIEW_DELIVERABLE_PATTERN, " ")
|
|
1173
|
+
.replace(RECOVERY_REVIEW_CONTEXT_OBJECT_PATTERN, " ")
|
|
1174
|
+
.replace(RECOVERY_REVIEW_MUTATION_NOUN_CONTEXT_PATTERN, " ")
|
|
1175
|
+
.replace(RECOVERY_REVIEW_PRIOR_FIX_CONTEXT_PATTERN, " ")
|
|
1176
|
+
.replace(RECOVERY_REVIEW_DETECTION_CONTEXT_PATTERN, " ")
|
|
1177
|
+
.replace(RECOVERY_REVIEW_PATTERN_CHANGE_CONTEXT_PATTERN, " ")
|
|
1178
|
+
.replace(RECOVERY_REVIEW_GIT_PATTERN_CHANGE_CONTEXT_PATTERN, " ")
|
|
1179
|
+
.replace(RECOVERY_REVIEW_VALIDATION_EVIDENCE_PATTERN, " ")
|
|
1180
|
+
.replace(RECOVERY_REVIEW_CONTRACT_PROHIBITION_PATTERN, " ")
|
|
1181
|
+
.replace(RECOVERY_REVIEW_ONLY_REVIEW_CONTRACT_PATTERN, " ")
|
|
1182
|
+
.replace(RECOVERY_REVIEW_BLOCKED_QUOTED_EXAMPLE_PATTERN, " ")
|
|
1183
|
+
.replace(RECOVERY_REVIEW_CONTEXT_QUOTED_EXAMPLE_PATTERN, " ")
|
|
1184
|
+
.replace(RECOVERY_REVIEW_LISTED_BLOCKED_EXAMPLE_PATTERN, " ")
|
|
1185
|
+
.replace(RECOVERY_REVIEW_BLOCKS_QUOTED_EXAMPLE_PATTERN, " ")
|
|
1186
|
+
.replace(RECOVERY_REVIEW_EXAMPLES_LIKE_BLOCKED_PATTERN, " ")
|
|
1187
|
+
.replace(RECOVERY_REVIEW_QUOTED_VISIBLE_BLOCKED_PATTERN, " ")
|
|
1188
|
+
.replace(RECOVERY_REVIEW_PROMPTS_LIKE_BLOCKED_PATTERN, " ")
|
|
1189
|
+
.replace(RECOVERY_REVIEW_GIT_MUTATIONS_BROADER_CONTEXT_PATTERN, " ")
|
|
1190
|
+
.replace(RECOVERY_REVIEW_GIT_COVERAGE_CONTEXT_PATTERN, " ")
|
|
1191
|
+
.replace(RECOVERY_REVIEW_REGRESSION_QUOTED_EXAMPLE_PATTERN, " ")
|
|
1192
|
+
.replace(RECOVERY_REVIEW_REGRESSION_ANAPHORIC_EXAMPLE_PATTERN, " ")
|
|
1193
|
+
.replace(RECOVERY_REVIEW_REGRESSION_FOLLOWED_BY_ANAPHORIC_PATTERN, " ")
|
|
1194
|
+
.replace(RECOVERY_REVIEW_BLOCKED_LIVE_VARIANT_CONTEXT_PATTERN, " ")
|
|
1195
|
+
.replace(RECOVERY_REVIEW_ANAPHORIC_REFERENCES_CONTEXT_PATTERN, " ");
|
|
1196
|
+
const taskMutationText = task
|
|
1197
|
+
.replace(RECOVERY_REVIEW_DELIVERABLE_PATTERN, " ")
|
|
1198
|
+
.replace(RECOVERY_REVIEW_CONTEXT_OBJECT_PATTERN, " ")
|
|
1199
|
+
.replace(RECOVERY_REVIEW_MUTATION_NOUN_CONTEXT_PATTERN, " ")
|
|
1200
|
+
.replace(RECOVERY_REVIEW_PRIOR_FIX_CONTEXT_PATTERN, " ")
|
|
1201
|
+
.replace(RECOVERY_REVIEW_DETECTION_CONTEXT_PATTERN, " ")
|
|
1202
|
+
.replace(RECOVERY_REVIEW_PATTERN_CHANGE_CONTEXT_PATTERN, " ")
|
|
1203
|
+
.replace(RECOVERY_REVIEW_GIT_PATTERN_CHANGE_CONTEXT_PATTERN, " ")
|
|
1204
|
+
.replace(RECOVERY_REVIEW_VALIDATION_EVIDENCE_PATTERN, " ")
|
|
1205
|
+
.replace(RECOVERY_REVIEW_CONTRACT_PROHIBITION_PATTERN, " ")
|
|
1206
|
+
.replace(RECOVERY_REVIEW_ONLY_REVIEW_CONTRACT_PATTERN, " ")
|
|
1207
|
+
.replace(RECOVERY_REVIEW_BLOCKED_CONTEXT_FRAGMENT_PATTERN, " ")
|
|
1208
|
+
.replace(RECOVERY_REVIEW_REGRESSION_EVIDENCE_PATTERN, " ")
|
|
1209
|
+
.replace(RECOVERY_REVIEW_BLOCKED_QUOTED_EXAMPLE_PATTERN, " ")
|
|
1210
|
+
.replace(RECOVERY_REVIEW_CONTEXT_QUOTED_EXAMPLE_PATTERN, " ")
|
|
1211
|
+
.replace(RECOVERY_REVIEW_LISTED_BLOCKED_EXAMPLE_PATTERN, " ")
|
|
1212
|
+
.replace(RECOVERY_REVIEW_BLOCKS_QUOTED_EXAMPLE_PATTERN, " ")
|
|
1213
|
+
.replace(RECOVERY_REVIEW_EXAMPLES_LIKE_BLOCKED_PATTERN, " ")
|
|
1214
|
+
.replace(RECOVERY_REVIEW_QUOTED_VISIBLE_BLOCKED_PATTERN, " ")
|
|
1215
|
+
.replace(RECOVERY_REVIEW_PROMPTS_LIKE_BLOCKED_PATTERN, " ")
|
|
1216
|
+
.replace(RECOVERY_REVIEW_GIT_MUTATIONS_BROADER_CONTEXT_PATTERN, " ")
|
|
1217
|
+
.replace(RECOVERY_REVIEW_GIT_COVERAGE_CONTEXT_PATTERN, " ")
|
|
1218
|
+
.replace(RECOVERY_REVIEW_REGRESSION_QUOTED_EXAMPLE_PATTERN, " ")
|
|
1219
|
+
.replace(RECOVERY_REVIEW_REGRESSION_ANAPHORIC_EXAMPLE_PATTERN, " ")
|
|
1220
|
+
.replace(RECOVERY_REVIEW_REGRESSION_FOLLOWED_BY_ANAPHORIC_PATTERN, " ")
|
|
1221
|
+
.replace(RECOVERY_REVIEW_BLOCKED_LIVE_VARIANT_CONTEXT_PATTERN, " ")
|
|
1222
|
+
.replace(RECOVERY_REVIEW_ANAPHORIC_REFERENCES_CONTEXT_PATTERN, " ")
|
|
1223
|
+
.replace(RECOVERY_REVIEW_NO_ANAPHORIC_MUTATION_CLAUSE_PATTERN, " ")
|
|
1224
|
+
.replace(RECOVERY_REVIEW_NO_DELEGATION_CLAUSE_PATTERN, " ")
|
|
1225
|
+
.replace(RECOVERY_REVIEW_NO_MUTATION_CLAUSE_PATTERN, " ")
|
|
1226
|
+
.replace(new RegExp(RECOVERY_REVIEW_READ_ONLY_PATTERN.source, "gi"), " ");
|
|
1227
|
+
return params.acceptance === false
|
|
1228
|
+
&& agent !== ""
|
|
1229
|
+
&& /\b(?:advisor|oracle|review|reviewer)\b/i.test(agent)
|
|
1230
|
+
&& RECOVERY_REVIEW_READ_ONLY_PATTERN.test(task)
|
|
1231
|
+
&& !RECOVERY_REVIEW_DESTRUCTIVE_COMMAND_PATTERN.test(taskDestructiveCommandText)
|
|
1232
|
+
&& !RECOVERY_REVIEW_MUTATION_VERB_PATTERN.test(taskMutationText)
|
|
1233
|
+
&& !RECOVERY_REVIEW_DELEGATION_PATTERN.test(taskMutationText)
|
|
1234
|
+
&& !RECOVERY_REVIEW_ANAPHORIC_MUTATION_PATTERN.test(taskMutationText)
|
|
1235
|
+
&& !RECOVERY_REVIEW_DESTRUCTIVE_COMMAND_PATTERN.test(taskMutationText)
|
|
1236
|
+
&& !RECOVERY_REVIEW_DASH_LIVE_ACTION_PATTERN.test(taskDashLiveActionText)
|
|
1237
|
+
&& classifyTaskMutationIntent(agent, task).kind === "read-only";
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function recoveryBarrierMessage(sourceKey: string, target: string): string {
|
|
1241
|
+
return `Run '${target}' cannot launch after run '${sourceKey}' returned rejected acceptance recovery; only explicit read-only review children with acceptance:false may follow.`;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1110
1244
|
function isPlainJsonObject(value: unknown): value is Record<string, unknown> {
|
|
1111
1245
|
if (!isRecord(value)) return false;
|
|
1112
1246
|
const prototype = Object.getPrototypeOf(value);
|
|
@@ -1518,7 +1652,7 @@ function setupAbortResumeParams(params: Record<string, unknown>, result: Workflo
|
|
|
1518
1652
|
export async function runWorkflowScript(options: RunWorkflowScriptOptions): Promise<WorkflowScriptResult> {
|
|
1519
1653
|
if (!options.script.trim()) throw new Error("workflowScript must not be empty.");
|
|
1520
1654
|
if (options.timeoutMs !== undefined && (!Number.isInteger(options.timeoutMs) || options.timeoutMs < 1)) throw new Error("workflow script timeout must be a positive integer.");
|
|
1521
|
-
if (options.globalConcurrencyLimit !== undefined && (!Number.
|
|
1655
|
+
if (options.globalConcurrencyLimit !== undefined && (!Number.isSafeInteger(options.globalConcurrencyLimit) || options.globalConcurrencyLimit < 1)) {
|
|
1522
1656
|
throw new Error("workflow script global concurrency limit must be a positive integer.");
|
|
1523
1657
|
}
|
|
1524
1658
|
const launchSemaphore = new Semaphore(options.globalConcurrencyLimit ?? DEFAULT_GLOBAL_CONCURRENCY_LIMIT);
|
|
@@ -1545,6 +1679,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
|
|
|
1545
1679
|
const observedSteerCalls = new Set<number>();
|
|
1546
1680
|
const observedHostCalls = new Set<number>();
|
|
1547
1681
|
const childController = new AbortController();
|
|
1682
|
+
let acceptanceRecoveryBarrier: { key: string } | undefined;
|
|
1548
1683
|
let settled = false;
|
|
1549
1684
|
let finishing = false;
|
|
1550
1685
|
|
|
@@ -1583,6 +1718,13 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
|
|
|
1583
1718
|
const text = error instanceof Error ? error.message : String(error);
|
|
1584
1719
|
return { key, ok: false, output: text, error: text, artifactPaths: [] };
|
|
1585
1720
|
};
|
|
1721
|
+
const assertRecoveryBarrierAllowsRun = (key: string, params: Record<string, unknown>): void => {
|
|
1722
|
+
if (!acceptanceRecoveryBarrier || isExplicitReadOnlyRecoveryReview(params)) return;
|
|
1723
|
+
throw new Error(recoveryBarrierMessage(acceptanceRecoveryBarrier.key, key));
|
|
1724
|
+
};
|
|
1725
|
+
const recordAcceptanceRecoveryBarrier = (key: string, result: WorkflowScriptChildResult): void => {
|
|
1726
|
+
if (isAcceptanceMetadataRecovery(result)) acceptanceRecoveryBarrier = { key };
|
|
1727
|
+
};
|
|
1586
1728
|
const stopChild = (key: string, message = `Workflow child '${key}' stopped by user.`): boolean => {
|
|
1587
1729
|
if (!launches.has(key) || children.has(key)) return false;
|
|
1588
1730
|
stoppedLaunches.add(key);
|
|
@@ -1763,6 +1905,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
|
|
|
1763
1905
|
return respond(Promise.reject(error));
|
|
1764
1906
|
}
|
|
1765
1907
|
if (message.method === "state.get") return respond(Promise.resolve().then(() => options.state!.get(key)));
|
|
1908
|
+
if (acceptanceRecoveryBarrier) return respond(Promise.reject(new Error(recoveryBarrierMessage(acceptanceRecoveryBarrier.key, `state.set('${key}')`))));
|
|
1766
1909
|
const value = message.args.value;
|
|
1767
1910
|
try {
|
|
1768
1911
|
assertWorkflowJsonValue(value, `state.set('${key}') value`);
|
|
@@ -1799,6 +1942,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
|
|
|
1799
1942
|
const steerMessage = message.args.message;
|
|
1800
1943
|
if (typeof steerMessage !== "string" || !steerMessage.trim()) return respond(Promise.reject(new Error(`runs.steer('${key}') requires a non-empty message.`)));
|
|
1801
1944
|
const steerOptions = isRecord(message.args.options) ? message.args.options as WorkflowSteerOptions : {};
|
|
1945
|
+
if (acceptanceRecoveryBarrier) return respond(Promise.reject(new Error(recoveryBarrierMessage(acceptanceRecoveryBarrier.key, `runs.steer('${key}')`))));
|
|
1802
1946
|
const startedAt = Date.now();
|
|
1803
1947
|
trace.push({ operation: "steer", key, state: "started" });
|
|
1804
1948
|
traceChanged();
|
|
@@ -1829,6 +1973,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
|
|
|
1829
1973
|
} catch (error) {
|
|
1830
1974
|
return respond(Promise.reject(error));
|
|
1831
1975
|
}
|
|
1976
|
+
if (acceptanceRecoveryBarrier) return respond(Promise.reject(new Error(recoveryBarrierMessage(acceptanceRecoveryBarrier.key, `runs.host('${key}')`))));
|
|
1832
1977
|
if (!options.host) return respond(Promise.reject(new Error("runs.host is unavailable in this host context.")));
|
|
1833
1978
|
if (hostCalls.size >= HOST_STEP_MAX_COUNT) return respond(Promise.reject(new Error(`workflowScript supports at most ${HOST_STEP_MAX_COUNT} runs.host calls.`)));
|
|
1834
1979
|
const startedAt = Date.now();
|
|
@@ -1884,7 +2029,9 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
|
|
|
1884
2029
|
const deliver = (promise: Promise<WorkflowScriptChildResult>) => collectFailure
|
|
1885
2030
|
? promise
|
|
1886
2031
|
: promise.then((result) => {
|
|
1887
|
-
|
|
2032
|
+
const recoverableAcceptanceMetadata = result.recovery?.status === "available-for-review"
|
|
2033
|
+
&& result.recovery.reason === "acceptance-metadata-rejected";
|
|
2034
|
+
if (!result.ok && !result.stopped && !recoverableAcceptanceMetadata) {
|
|
1888
2035
|
const childError = new Error(result.detached ? `Run '${key}' detached: ${result.error ?? result.output}` : `Run '${key}' failed: ${result.error ?? result.output}`) as Error & { workflowErrorKind?: "detached-child" };
|
|
1889
2036
|
if (result.detached) childError.workflowErrorKind = "detached-child";
|
|
1890
2037
|
throw childError;
|
|
@@ -1948,6 +2095,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
|
|
|
1948
2095
|
});
|
|
1949
2096
|
admission = Promise.resolve().then(() => {
|
|
1950
2097
|
if (settled || finishing) return;
|
|
2098
|
+
for (const call of calls) assertRecoveryBarrierAllowsRun(call.key, call.params);
|
|
1951
2099
|
return options.admit?.(calls);
|
|
1952
2100
|
});
|
|
1953
2101
|
if (batch) batchAdmissions.set(batch.id, admission);
|
|
@@ -2008,6 +2156,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
|
|
|
2008
2156
|
childStopControllers.delete(key);
|
|
2009
2157
|
if (stoppedLaunches.has(key)) return children.get(key) ?? normalized;
|
|
2010
2158
|
children.set(key, normalized);
|
|
2159
|
+
recordAcceptanceRecoveryBarrier(key, normalized);
|
|
2011
2160
|
const state = normalized.ok ? "completed" : normalized.stopped ? "stopped" : normalized.detached ? "detached" : "failed";
|
|
2012
2161
|
trace.push({ operation: "run", key, state, durationMs: Date.now() - startedAt, ...workflowStringMetadata(params), ...(generatedLaneKey ? { generatedLaneKey } : {}), ...(normalized.agent ? { agent: normalized.agent } : {}), ...(normalized.runId ? { runId: normalized.runId } : {}), ...(!normalized.ok ? { error: normalized.error ?? normalized.output } : {}) });
|
|
2013
2162
|
traceChanged();
|
|
@@ -117,5 +117,5 @@ export function parseWorkflowChildSummary(value: unknown): WorkflowChildSummaryV
|
|
|
117
117
|
if (new Set(children.map((child) => child.childId)).size !== children.length) throw new Error("workflowChildren has duplicate childId values.");
|
|
118
118
|
if (typeof input.parentToolCallId !== "string") throw new Error("workflowChildren.parentToolCallId is invalid.");
|
|
119
119
|
if (typeof input.workflowRunId !== "string") throw new Error("workflowChildren.workflowRunId is invalid.");
|
|
120
|
-
return { version: 1, parentToolCallId: requiredId(input.parentToolCallId, "parentToolCallId"), workflowRunId: requiredId(input.workflowRunId, "workflowRunId"), inventoryComplete: input.inventoryComplete, workflowState, children };
|
|
120
|
+
return { version: 1, parentToolCallId: requiredId(input.parentToolCallId, "workflowChildren.parentToolCallId"), workflowRunId: requiredId(input.workflowRunId, "workflowChildren.workflowRunId"), inventoryComplete: input.inventoryComplete, workflowState, children };
|
|
121
121
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { writePrivateAtomicJson } from "../shared/atomic-json.ts";
|
|
4
|
-
import type { ExternalCliReceiptMetadata, WorkflowReceipt, WorkflowReceiptEntry, WorkflowReceiptState, WorkflowRecoveryAction, WorkflowTerminalOutcome, WorkflowTerminalResolution } from "../shared/types.ts";
|
|
4
|
+
import type { AcceptanceRecoveryMetadata, ExternalCliReceiptMetadata, WorkflowReceipt, WorkflowReceiptEntry, WorkflowReceiptState, WorkflowRecoveryAction, WorkflowResourceProvenanceV1, WorkflowTerminalOutcome, WorkflowTerminalResolution } from "../shared/types.ts";
|
|
5
5
|
import type { WorkflowReceiptResumeReference, WorkflowScriptChildResult } from "./scripted-workflow.ts";
|
|
6
6
|
import { parseWorkflowChildSummary } from "./workflow-child-summary.ts";
|
|
7
7
|
import { HOST_STEP_MAX_COUNT, assertUniqueHostStepIds, parseHostStepNode } from "../runs/shared/host-step-status.ts";
|
|
@@ -14,6 +14,7 @@ export const WORKFLOW_RECEIPT_FILE = "workflow-receipt.json";
|
|
|
14
14
|
const MAX_WORKFLOW_RECEIPT_BYTES = 2 * 1024 * 1024;
|
|
15
15
|
|
|
16
16
|
const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
17
|
+
const RESOURCE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
17
18
|
|
|
18
19
|
function assertSafeRunId(value: string, label: string): string {
|
|
19
20
|
const normalized = value.trim();
|
|
@@ -38,6 +39,7 @@ export function buildWorkflowReceipt(input: {
|
|
|
38
39
|
children: WorkflowScriptChildResult[];
|
|
39
40
|
hostSteps?: WorkflowReceipt["hostSteps"];
|
|
40
41
|
workflowChildren?: WorkflowReceipt["workflowChildren"];
|
|
42
|
+
resource?: WorkflowResourceProvenanceV1;
|
|
41
43
|
terminalOutcome?: WorkflowTerminalOutcome;
|
|
42
44
|
createdAt?: number;
|
|
43
45
|
}): WorkflowReceipt {
|
|
@@ -61,6 +63,7 @@ export function buildWorkflowReceipt(input: {
|
|
|
61
63
|
...(child.requestedContext ? { requestedContext: child.requestedContext } : {}),
|
|
62
64
|
...(child.resolvedContext ? { resolvedContext: child.resolvedContext } : {}),
|
|
63
65
|
...(child.outputReference ? { outputReference: child.outputReference } : {}),
|
|
66
|
+
...(child.recovery ? { acceptanceRecovery: child.recovery } : {}),
|
|
64
67
|
...(child.externalAdapter ? { externalAdapter: child.externalAdapter } : {}),
|
|
65
68
|
continuation: { runIds },
|
|
66
69
|
};
|
|
@@ -71,7 +74,8 @@ export function buildWorkflowReceipt(input: {
|
|
|
71
74
|
if (input.hostSteps && input.hostSteps.length > HOST_STEP_MAX_COUNT) throw new Error(`Workflow receipt has more than ${HOST_STEP_MAX_COUNT} host steps.`);
|
|
72
75
|
const hostSteps = input.hostSteps?.map((hostStep, index) => parseHostStepNode(hostStep, `workflow receipt hostSteps[${index}]`));
|
|
73
76
|
if (hostSteps) assertUniqueHostStepIds(hostSteps, "workflow receipt");
|
|
74
|
-
|
|
77
|
+
const resource = parseWorkflowResource(input.resource, "workflow receipt");
|
|
78
|
+
return { version: WORKFLOW_RECEIPT_VERSION, workflowRunId, state: input.state, createdAt: input.createdAt ?? Date.now(), entries, ...(resource ? { resource } : {}), ...(hostSteps?.length ? { hostSteps } : {}), ...(input.workflowChildren ? { workflowChildren: input.workflowChildren } : {}), ...(input.terminalOutcome ? { terminalOutcome: input.terminalOutcome } : {}) };
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
export function writeWorkflowReceipt(asyncDir: string, receipt: WorkflowReceipt): string {
|
|
@@ -210,6 +214,20 @@ function parseTerminalOutcome(value: unknown, label: string): WorkflowTerminalOu
|
|
|
210
214
|
return { state: "partial", reason: outcome.reason };
|
|
211
215
|
}
|
|
212
216
|
|
|
217
|
+
function parseAcceptanceRecoveryMetadata(value: unknown, label: string): AcceptanceRecoveryMetadata | undefined {
|
|
218
|
+
if (value === undefined) return undefined;
|
|
219
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object.`);
|
|
220
|
+
const recovery = value as Record<string, unknown>;
|
|
221
|
+
if (recovery.status !== "available-for-review" || recovery.reason !== "acceptance-metadata-rejected") throw new Error(`${label} is invalid.`);
|
|
222
|
+
if (typeof recovery.reportPath !== "string" || !recovery.reportPath.trim() || typeof recovery.reportHash !== "string" || !/^[a-f0-9]{64}$/u.test(recovery.reportHash)) throw new Error(`${label} is invalid.`);
|
|
223
|
+
return {
|
|
224
|
+
status: "available-for-review",
|
|
225
|
+
reason: "acceptance-metadata-rejected",
|
|
226
|
+
reportPath: recovery.reportPath,
|
|
227
|
+
reportHash: recovery.reportHash,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
213
231
|
function parseEntry(value: unknown, key: string, source: string): WorkflowReceiptEntry {
|
|
214
232
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid workflow receipt '${source}': entry '${key}' must be an object.`);
|
|
215
233
|
const entry = value as Record<string, unknown>;
|
|
@@ -231,10 +249,11 @@ function parseEntry(value: unknown, key: string, source: string): WorkflowReceip
|
|
|
231
249
|
if (state === "resumable" && latestRunId === undefined) throw new Error(`Invalid workflow receipt '${source}': entry '${key}' resumable entry has no retained run id.`);
|
|
232
250
|
if (state === "not-resumable" && (typeof reason !== "string" || !reason.trim())) throw new Error(`Invalid workflow receipt '${source}': entry '${key}' non-resumable reason is missing.`);
|
|
233
251
|
const terminalOutcome = parseTerminalOutcome(entry.terminalOutcome, `Invalid workflow receipt '${source}': entry '${key}' terminalOutcome`);
|
|
252
|
+
const acceptanceRecovery = parseAcceptanceRecoveryMetadata(entry.acceptanceRecovery, `Invalid workflow receipt '${source}': entry '${key}' acceptanceRecovery`);
|
|
234
253
|
parseExternalCliReceiptMetadata(entry.externalAdapter, key, source);
|
|
235
254
|
const lane = normalizeWorkflowLaneMetadata(entry.lane, `Invalid workflow receipt '${source}': entry '${key}'.lane`);
|
|
236
255
|
assertWorkflowLaneKey(lane, key, `Invalid workflow receipt '${source}': entry '${key}'.lane`);
|
|
237
|
-
return { ...(value as WorkflowReceiptEntry), ...(lane ? { lane } : {}), ...(terminalOutcome ? { terminalOutcome } : {}) };
|
|
256
|
+
return { ...(value as WorkflowReceiptEntry), ...(lane ? { lane } : {}), ...(terminalOutcome ? { terminalOutcome } : {}), ...(acceptanceRecovery ? { acceptanceRecovery } : {}) };
|
|
238
257
|
}
|
|
239
258
|
|
|
240
259
|
function parseWorkflowResolution(value: unknown, source: string): WorkflowTerminalResolution | undefined {
|
|
@@ -243,6 +262,23 @@ function parseWorkflowResolution(value: unknown, source: string): WorkflowTermin
|
|
|
243
262
|
return value;
|
|
244
263
|
}
|
|
245
264
|
|
|
265
|
+
function parseWorkflowResource(value: unknown, source: string): WorkflowResourceProvenanceV1 | undefined {
|
|
266
|
+
if (value === undefined) return undefined;
|
|
267
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid workflow receipt '${source}': resource must be an object.`);
|
|
268
|
+
const resource = value as Record<string, unknown>;
|
|
269
|
+
if (resource.kind !== "workflow" || typeof resource.name !== "string" || !KEY_PATTERN.test(resource.name) || !Number.isInteger(resource.version) || (resource.version as number) < 1 || (resource.version as number) > 1_000_000 || resource.invocation !== "named" || resource.expansion !== "resolved" || typeof resource.id !== "string" || !RESOURCE_ID_PATTERN.test(resource.id)) {
|
|
270
|
+
throw new Error(`Invalid workflow receipt '${source}': resource is invalid.`);
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
kind: "workflow",
|
|
274
|
+
name: resource.name,
|
|
275
|
+
version: resource.version as number,
|
|
276
|
+
invocation: "named",
|
|
277
|
+
expansion: "resolved",
|
|
278
|
+
id: resource.id,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
246
282
|
function parseRecovery(value: unknown, workflowRunId: string, entries: Record<string, WorkflowReceiptEntry>, source: string): WorkflowRecoveryAction[] | undefined {
|
|
247
283
|
if (value === undefined) return undefined;
|
|
248
284
|
if (!Array.isArray(value)) throw new Error(`Invalid workflow receipt '${source}': recovery must be an array.`);
|
|
@@ -294,9 +330,10 @@ export function readWorkflowReceipt(asyncDirRoot: string, workflowRunId: string)
|
|
|
294
330
|
assertUniqueHostStepIds(hostSteps, receiptPath);
|
|
295
331
|
}
|
|
296
332
|
const workflowResolution = parseWorkflowResolution(receipt.workflowResolution, receiptPath);
|
|
333
|
+
const resource = parseWorkflowResource(receipt.resource, receiptPath);
|
|
297
334
|
const terminalOutcome = parseTerminalOutcome(receipt.terminalOutcome, `Invalid workflow receipt '${receiptPath}': terminalOutcome`);
|
|
298
335
|
const recovery = parseRecovery(receipt.recovery, workflowRunId, entries, receiptPath);
|
|
299
|
-
return { version: 1, workflowRunId, state: receipt.state, createdAt: receipt.createdAt, entries, ...(hostSteps?.length ? { hostSteps } : {}), ...(workflowChildren ? { workflowChildren } : {}), ...(workflowResolution ? { workflowResolution } : {}), ...(terminalOutcome ? { terminalOutcome } : {}), ...(recovery ? { recovery } : {}) };
|
|
336
|
+
return { version: 1, workflowRunId, state: receipt.state, createdAt: receipt.createdAt, entries, ...(resource ? { resource } : {}), ...(hostSteps?.length ? { hostSteps } : {}), ...(workflowChildren ? { workflowChildren } : {}), ...(workflowResolution ? { workflowResolution } : {}), ...(terminalOutcome ? { terminalOutcome } : {}), ...(recovery ? { recovery } : {}) };
|
|
300
337
|
}
|
|
301
338
|
|
|
302
339
|
export function resolveWorkflowReceiptResumeEntry(input: {
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { stableJsonDigest } from "../shared/launch-contract.ts";
|
|
3
|
+
import {
|
|
4
|
+
createWorkflowResourcePermit,
|
|
5
|
+
type WorkflowResourceAuthority,
|
|
6
|
+
type WorkflowResourcePermit,
|
|
7
|
+
} from "../shared/workflow-child-permit.ts";
|
|
8
|
+
import type { WorkflowResourceProvenanceV1 } from "../shared/types.ts";
|
|
9
|
+
|
|
10
|
+
const RESOURCE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
11
|
+
const MAX_ARGS_BYTES = 16 * 1024;
|
|
12
|
+
const MAX_STRING_BYTES = 16 * 1024;
|
|
13
|
+
|
|
14
|
+
export interface ResolvedWorkflowResource {
|
|
15
|
+
script: string;
|
|
16
|
+
permit: WorkflowResourcePermit;
|
|
17
|
+
provenance: WorkflowResourceProvenanceV1;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type WorkflowResourceResolution =
|
|
21
|
+
| { ok: true; resource: ResolvedWorkflowResource }
|
|
22
|
+
| { ok: false; error: string };
|
|
23
|
+
|
|
24
|
+
type WorkflowResourceDefinition = {
|
|
25
|
+
name: string;
|
|
26
|
+
version: number;
|
|
27
|
+
resolve: (args: Record<string, unknown>) => { script: string; authority: WorkflowResourceAuthority } | { error: string };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
31
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
32
|
+
const prototype = Object.getPrototypeOf(value);
|
|
33
|
+
return prototype === Object.prototype || prototype === null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function jsonByteLength(value: unknown): number {
|
|
37
|
+
try {
|
|
38
|
+
const encoded = JSON.stringify(value);
|
|
39
|
+
if (encoded === undefined) throw new Error("must contain JSON data");
|
|
40
|
+
return Buffer.byteLength(encoded, "utf8");
|
|
41
|
+
} catch (error) {
|
|
42
|
+
throw new Error(`must contain plain JSON data: ${error instanceof Error ? error.message : String(error)}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validatePlainJson(value: unknown, path: string, depth = 0): void {
|
|
47
|
+
if (depth > 8) throw new Error(`${path} is too deeply nested.`);
|
|
48
|
+
if (value === null || typeof value === "boolean") return;
|
|
49
|
+
if (typeof value === "string") {
|
|
50
|
+
if (!value.trim()) throw new Error(`${path} must not be empty.`);
|
|
51
|
+
if (Buffer.byteLength(value, "utf8") > MAX_STRING_BYTES) throw new Error(`${path} exceeds ${MAX_STRING_BYTES} bytes.`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (typeof value === "number") {
|
|
55
|
+
if (!Number.isFinite(value)) throw new Error(`${path} must be finite.`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (Array.isArray(value)) {
|
|
59
|
+
if (value.length > 64) throw new Error(`${path} contains too many items.`);
|
|
60
|
+
for (const [index, entry] of value.entries()) validatePlainJson(entry, `${path}[${index}]`, depth + 1);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (!isPlainRecord(value)) throw new Error(`${path} must contain plain JSON data.`);
|
|
64
|
+
if (Object.keys(value).length > 16) throw new Error(`${path} contains too many fields.`);
|
|
65
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
66
|
+
if (!key.trim()) throw new Error(`${path} contains an empty field name.`);
|
|
67
|
+
validatePlainJson(entry, `${path}.${key}`, depth + 1);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeArgs(value: unknown): { args?: Record<string, unknown>; error?: string } {
|
|
72
|
+
if (value === undefined) return { args: {} };
|
|
73
|
+
if (!isPlainRecord(value)) return { error: "workflow args must be a plain JSON object." };
|
|
74
|
+
try {
|
|
75
|
+
validatePlainJson(value, "workflow args");
|
|
76
|
+
if (jsonByteLength(value) > MAX_ARGS_BYTES) return { error: `workflow args exceed ${MAX_ARGS_BYTES} bytes.` };
|
|
77
|
+
return { args: { ...value } };
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function resolveRunCi(args: Record<string, unknown>): { script: string; authority: WorkflowResourceAuthority } | { error: string } {
|
|
84
|
+
const allowed = new Set(["command", "timeoutMs"]);
|
|
85
|
+
const unsupported = Object.keys(args).filter((key) => !allowed.has(key));
|
|
86
|
+
if (unsupported.length > 0) return { error: `workflow 'run-ci' args contain unsupported fields: ${unsupported.join(", ")}.` };
|
|
87
|
+
const command = args.command === undefined ? "npm test" : args.command;
|
|
88
|
+
if (command !== "npm test" && command !== "npm run typecheck") return { error: "workflow 'run-ci' args.command must be 'npm test' or 'npm run typecheck'." };
|
|
89
|
+
const timeoutMs = args.timeoutMs === undefined ? 120_000 : args.timeoutMs;
|
|
90
|
+
if (typeof timeoutMs !== "number" || !Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 86_400_000) return { error: "workflow 'run-ci' args.timeoutMs must be an integer from 1 to 86400000." };
|
|
91
|
+
const params = { kind: "command", command, timeoutMs, role: "ci" };
|
|
92
|
+
return {
|
|
93
|
+
script: `return await runs.host("ci", ${JSON.stringify(params)});`,
|
|
94
|
+
authority: { host: { keys: ["ci"], commands: [command] } },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolveReview(args: Record<string, unknown>): { script: string; authority: WorkflowResourceAuthority } | { error: string } {
|
|
99
|
+
const unsupported = Object.keys(args).filter((key) => key !== "task");
|
|
100
|
+
if (unsupported.length > 0) return { error: `workflow 'review' args contain unsupported fields: ${unsupported.join(", ")}.` };
|
|
101
|
+
const task = args.task;
|
|
102
|
+
if (typeof task !== "string" || !task.trim()) return { error: "workflow 'review' requires a non-empty string args.task." };
|
|
103
|
+
return {
|
|
104
|
+
script: `return (await runs.run("review", { agent: "reviewer", task: ${JSON.stringify(task.trim())} })).output;`,
|
|
105
|
+
authority: {},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const WORKFLOW_RESOURCES: readonly WorkflowResourceDefinition[] = [
|
|
110
|
+
{ name: "review", version: 1, resolve: resolveReview },
|
|
111
|
+
{ name: "run-ci", version: 1, resolve: resolveRunCi },
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
function findWorkflowResource(name: string): WorkflowResourceDefinition | undefined {
|
|
115
|
+
return WORKFLOW_RESOURCES.find((resource) => resource.name === name);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function listWorkflowResourceNames(): string[] {
|
|
119
|
+
return WORKFLOW_RESOURCES.map((resource) => resource.name);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Resolve only extension-owned resources so policy can distinguish them from raw scripts; caller-provided script text is never consulted. */
|
|
123
|
+
export function resolveWorkflowResource(nameValue: unknown, argsValue?: unknown): WorkflowResourceResolution {
|
|
124
|
+
if (typeof nameValue !== "string" || !nameValue.trim()) return { ok: false, error: "workflow must be a non-empty resource name." };
|
|
125
|
+
const name = nameValue.trim();
|
|
126
|
+
if (!RESOURCE_NAME_PATTERN.test(name)) return { ok: false, error: "workflow must use a safe resource name." };
|
|
127
|
+
const resource = findWorkflowResource(name);
|
|
128
|
+
if (!resource) return { ok: false, error: `Unknown workflow resource '${name}'. Available resources: ${listWorkflowResourceNames().join(", ")}.` };
|
|
129
|
+
const normalizedArgs = normalizeArgs(argsValue);
|
|
130
|
+
if (normalizedArgs.error) return { ok: false, error: normalizedArgs.error };
|
|
131
|
+
const resolved = resource.resolve(normalizedArgs.args!);
|
|
132
|
+
if ("error" in resolved) return { ok: false, error: resolved.error };
|
|
133
|
+
const resourceId = randomUUID();
|
|
134
|
+
const permit = createWorkflowResourcePermit({
|
|
135
|
+
resourceName: resource.name,
|
|
136
|
+
resourceVersion: resource.version,
|
|
137
|
+
resourceId,
|
|
138
|
+
scriptDigest: stableJsonDigest(resolved.script),
|
|
139
|
+
authority: resolved.authority,
|
|
140
|
+
});
|
|
141
|
+
const provenance: WorkflowResourceProvenanceV1 = Object.freeze({
|
|
142
|
+
kind: "workflow",
|
|
143
|
+
name: resource.name,
|
|
144
|
+
version: resource.version,
|
|
145
|
+
invocation: "named",
|
|
146
|
+
expansion: "resolved",
|
|
147
|
+
id: resourceId,
|
|
148
|
+
});
|
|
149
|
+
return { ok: true, resource: { script: resolved.script, permit, provenance } };
|
|
150
|
+
}
|