@osovv/vv-opencode 1.3.8 → 1.4.1
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 +19 -0
- package/README.md +66 -14
- package/dist/commands/completion.js +10 -2
- package/dist/commands/completion.js.map +1 -1
- package/dist/commands/patch-provider.d.ts +148 -1
- package/dist/commands/patch-provider.js +151 -5
- package/dist/commands/patch-provider.js.map +1 -1
- package/dist/lib/orchestration.d.ts +2 -2
- package/dist/lib/orchestration.js +31 -2
- package/dist/lib/orchestration.js.map +1 -1
- package/dist/lib/spec-lint.d.ts +71 -1
- package/dist/lib/spec-lint.js +566 -8
- package/dist/lib/spec-lint.js.map +1 -1
- package/dist/lib/vvoc-config.d.ts +3 -3
- package/dist/lib/vvoc-preset-registry.d.ts +42 -30
- package/dist/lib/vvoc-preset-registry.js +41 -31
- package/dist/lib/vvoc-preset-registry.js.map +1 -1
- package/dist/plugins/hashline-edit/index.js.map +1 -1
- package/dist/plugins/workflow/checkpoint-io.d.ts +33 -0
- package/dist/plugins/workflow/checkpoint-io.js +224 -0
- package/dist/plugins/workflow/checkpoint-io.js.map +1 -0
- package/dist/plugins/workflow/checkpoints.d.ts +182 -0
- package/dist/plugins/workflow/checkpoints.js +1067 -0
- package/dist/plugins/workflow/checkpoints.js.map +1 -0
- package/dist/plugins/workflow/delegated.d.ts +182 -0
- package/dist/plugins/workflow/delegated.js +751 -0
- package/dist/plugins/workflow/delegated.js.map +1 -0
- package/dist/plugins/workflow/index.js +685 -23
- package/dist/plugins/workflow/index.js.map +1 -1
- package/dist/plugins/workflow/persistence.d.ts +32 -3
- package/dist/plugins/workflow/persistence.js +516 -70
- package/dist/plugins/workflow/persistence.js.map +1 -1
- package/dist/plugins/workflow/repair.js +3 -1
- package/dist/plugins/workflow/repair.js.map +1 -1
- package/dist/plugins/workflow/snapshots.d.ts +59 -0
- package/dist/plugins/workflow/snapshots.js +261 -0
- package/dist/plugins/workflow/snapshots.js.map +1 -0
- package/dist/plugins/workflow/state.d.ts +20 -2
- package/dist/plugins/workflow/state.js +127 -12
- package/dist/plugins/workflow/state.js.map +1 -1
- package/dist/plugins/workflow/tooling.d.ts +45 -0
- package/dist/plugins/workflow/tooling.js +344 -12
- package/dist/plugins/workflow/tooling.js.map +1 -1
- package/dist/plugins/workflow/transitions.js +2 -2
- package/dist/plugins/workflow/transitions.js.map +1 -1
- package/dist/tui/context/analyze.js +3 -1
- package/dist/tui/context/analyze.js.map +1 -1
- package/package.json +1 -1
- package/schemas/vvoc/v3.json +5 -3
- package/templates/agents/vv-code-reviewer.md +3 -1
- package/templates/agents/vv-implementer.md +5 -2
- package/templates/agents/vv-spec-reviewer.md +3 -0
- package/templates/skills/vv-execute/SKILL.md +62 -15
- package/templates/skills/vv-plan/SKILL.md +13 -3
- package/templates/skills/vv-plan/references/plan-template.xml +45 -0
- package/templates/skills/vv-review/SKILL.md +1 -0
|
@@ -1,32 +1,35 @@
|
|
|
1
1
|
// FILE: src/plugins/workflow/index.ts
|
|
2
|
-
// VERSION: 0.
|
|
2
|
+
// VERSION: 0.6.0
|
|
3
3
|
// START_MODULE_CONTRACT
|
|
4
|
-
// PURPOSE: Register workflow tools and enforcement while injecting only startup-profile-compatible vv-controller guidance.
|
|
5
|
-
// SCOPE: work_item_open/list/close registration, tracked launch validation, result normalization and repair, round aggregation with bounded excerpts, implementation round limits, persistence, and profile-selected chat.message guidance.
|
|
6
|
-
// DEPENDS: [@opencode-ai/plugin, src/lib/config-layers.ts, src/lib/orchestration.ts, src/lib/plugin-toggle-config.ts, src/plugins/workflow/persistence.ts, src/plugins/workflow/protocol.ts, src/plugins/workflow/repair.ts, src/plugins/workflow/state.ts, src/plugins/workflow/
|
|
7
|
-
// LINKS: M-PLUGIN-WORKFLOW, M-ORCHESTRATION-PROFILES, M-WORKFLOW-PROTOCOL, M-WORKFLOW-REPAIR, M-WORKFLOW-STATE, M-WORKFLOW-TRANSITIONS, M-WORKFLOW-TOOLING, M-WORKFLOW-PERSISTENCE, V-M-PLUGIN-WORKFLOW
|
|
4
|
+
// PURPOSE: Register workflow tools and enforcement while injecting only startup-profile-compatible vv-controller guidance, including delegated control tools, host-call-bound attempts, and checkpoint reviewer linkage.
|
|
5
|
+
// SCOPE: work_item_open/list/close registration, delegated-only work_item_decide and work_checkpoint registration with root-session authorization, tracked launch validation with delegated barriers and overlapping-write gates, live host-call bindings that convert supported foreground vv-implementer task launches into failed delegated attempts on confirmed host-terminal errors, result normalization and repair, callID-bound delegated attempt results and checkpoint reviewer bookkeeping, round aggregation with bounded excerpts, implementation round limits, checked persistence, and profile-selected chat.message guidance.
|
|
6
|
+
// DEPENDS: [@opencode-ai/plugin, src/lib/config-layers.ts, src/lib/orchestration.ts, src/lib/plugin-toggle-config.ts, src/plugins/workflow/checkpoint-io.ts, src/plugins/workflow/checkpoints.ts, src/plugins/workflow/delegated.ts, src/plugins/workflow/persistence.ts, src/plugins/workflow/protocol.ts, src/plugins/workflow/repair.ts, src/plugins/workflow/state.ts, src/plugins/workflow/tooling.ts, src/plugins/workflow/transitions.ts]
|
|
7
|
+
// LINKS: M-PLUGIN-WORKFLOW, M-ORCHESTRATION-PROFILES, M-WORKFLOW-PROTOCOL, M-WORKFLOW-REPAIR, M-WORKFLOW-STATE, M-WORKFLOW-TRANSITIONS, M-WORKFLOW-TOOLING, M-WORKFLOW-PERSISTENCE, M-WORKFLOW-DELEGATED, M-WORKFLOW-CHECKPOINTS, V-M-PLUGIN-WORKFLOW
|
|
8
8
|
// ROLE: RUNTIME
|
|
9
9
|
// MAP_MODE: EXPORTS
|
|
10
10
|
// END_MODULE_CONTRACT
|
|
11
11
|
//
|
|
12
12
|
// START_MODULE_MAP
|
|
13
|
-
// WorkflowPlugin - Registers workflow work-item tools, tracked task protocol enforcement, and primary-session workflow guidance injection.
|
|
13
|
+
// WorkflowPlugin - Registers workflow work-item tools, delegated control tools under the delegated profile, tracked task protocol enforcement with callID-bound delegated attempts, checkpoint linkage, and live host-call failure bindings, and primary-session workflow guidance injection.
|
|
14
14
|
// END_MODULE_MAP
|
|
15
15
|
//
|
|
16
16
|
// START_CHANGE_SUMMARY
|
|
17
|
-
// LAST_CHANGE: [
|
|
17
|
+
// LAST_CHANGE: [direct fix inFlightAttempt after failed worker launch - Added live host-call launch bindings so a confirmed foreground vv-implementer task failure is recorded as a failed delegated attempt instead of stranding inFlightAttempt until restart, with sticky exclusions for background/resume/cancellation/after-hook paths and staged checked persistence before retry is exposed.]
|
|
18
18
|
// END_CHANGE_SUMMARY
|
|
19
19
|
import { tool } from "@opencode-ai/plugin";
|
|
20
20
|
import { attemptTrackedResultRepair, isTrackedResultRepairEligible, unwrapResumableTaskResult, } from "./repair.js";
|
|
21
21
|
import { parseResultBlock, parseWorkItemHeader, TRACKED_SUBAGENT_NAMES, } from "./protocol.js";
|
|
22
|
-
import { applyTrackedResult, createWorkflowResultExcerpt, beginTrackedLaunch, createWorkItemStore, getReviewRound, getWorkItem, revertReviewerLaunch, } from "./state.js";
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
22
|
+
import { applyTrackedResult, createRecordLookupKey, createWorkflowResultExcerpt, beginTrackedLaunch, createWorkItemStore, getReviewRound, getWorkItem, revertReviewerLaunch, } from "./state.js";
|
|
23
|
+
import { applyDelegatedLaunchFailure, applyDelegatedResult, beginDelegatedLaunch, revertInFlightDelegatedLaunches, } from "./delegated.js";
|
|
24
|
+
import { checkpointBarrierUnsatisfied, findOverlappingInFlightReview, recordCheckpointReviewerLaunch, recordCheckpointReviewerResult, } from "./checkpoints.js";
|
|
25
|
+
import { getAllowedNextAgents, getAttemptedImplementationRound, getReviewerRoleForAgent, shouldBlockRound, } from "./transitions.js";
|
|
26
|
+
import { createWorkItemCloseTool, createWorkItemDecideTool, createWorkItemListTool, createWorkItemOpenTool, createWorkCheckpointTool, } from "./tooling.js";
|
|
25
27
|
import workflowSystemInstructionTemplate from "./system-instruction.md?raw";
|
|
26
28
|
import { loadVvocConfig } from "../../lib/config-layers.js";
|
|
27
29
|
import { resolveOrchestrationPolicy, } from "../../lib/orchestration.js";
|
|
28
30
|
import { isVvocPluginEnabled } from "../../lib/plugin-toggle-config.js";
|
|
29
|
-
import { deleteWorkflowSessionDir,
|
|
31
|
+
import { deleteWorkflowSessionDir, hydrateWorkflowStateChecked, snapshotWorkflowStateChecked, } from "./persistence.js";
|
|
32
|
+
import { loadApprovedDelegatedPlan } from "./checkpoint-io.js";
|
|
30
33
|
const z = tool.schema;
|
|
31
34
|
const TRACKED_SUBAGENT_SET = new Set(TRACKED_SUBAGENT_NAMES);
|
|
32
35
|
const WORK_ITEM_MISSING_MARKER = "__VVOC" + "_SECRET_BEARER_TOKEN_a6f582092f05__";
|
|
@@ -55,6 +58,28 @@ results are findings and do not route to an implementer. Treat BLOCKED and NEEDS
|
|
|
55
58
|
stops, validate delegated results yourself, run fresh verification, and close completed work items.
|
|
56
59
|
</workflow_protocol>
|
|
57
60
|
`.trim();
|
|
61
|
+
const DELEGATED_WORKFLOW_SYSTEM_INSTRUCTION = `
|
|
62
|
+
<workflow_protocol>
|
|
63
|
+
Delegated execution mechanics are available in this session. Open implementation tasks with
|
|
64
|
+
work_item_open using mode "delegated" and an empty requiredReviewers array, then dispatch exactly
|
|
65
|
+
one vv-implementer per task with a bounded task packet and the returned VVOC_WORK_ITEM_ID. Let the
|
|
66
|
+
worker complete its local edit, test, and fix cycle before it reports.
|
|
67
|
+
|
|
68
|
+
A DONE worker result waits in awaiting_acceptance: it never closes the work item and the worker
|
|
69
|
+
never accepts its own result. Inspect the changed code and evidence yourself, then call
|
|
70
|
+
work_item_decide with the attempt number to accept or request changes. DONE_WITH_CONCERNS requires
|
|
71
|
+
an explicit concerns disposition. Controller retries are bounded to one correction attempt before
|
|
72
|
+
explicit recovery; BLOCKED and NEEDS_CONTEXT remain hard stops.
|
|
73
|
+
|
|
74
|
+
Register an approved plan once with work_checkpoint register, start each declared review checkpoint
|
|
75
|
+
only after its prerequisite tasks are accepted, and run the due checkpoint before dependent waves.
|
|
76
|
+
A checkpoint passes only when every declared reviewer passes against the pinned snapshot; a closed
|
|
77
|
+
review-only FAIL report is a findings result, not approval. Do not write source files yourself:
|
|
78
|
+
delegate implementation edits, including fixes requested by reviewers, through bounded task
|
|
79
|
+
packets, while keeping planning artifacts, acceptance decisions, and verification commands in this
|
|
80
|
+
session.
|
|
81
|
+
</workflow_protocol>
|
|
82
|
+
`.trim();
|
|
58
83
|
/** Returns the exact workflow instruction compatible with one resolved policy. */
|
|
59
84
|
function getWorkflowSystemInstruction(policy) {
|
|
60
85
|
switch (policy.workflowGuidance) {
|
|
@@ -64,6 +89,8 @@ function getWorkflowSystemInstruction(policy) {
|
|
|
64
89
|
return SELECTIVE_WORKFLOW_SYSTEM_INSTRUCTION;
|
|
65
90
|
case "tracked":
|
|
66
91
|
return workflowSystemInstructionTemplate.trim();
|
|
92
|
+
case "delegated":
|
|
93
|
+
return DELEGATED_WORKFLOW_SYSTEM_INSTRUCTION;
|
|
67
94
|
}
|
|
68
95
|
}
|
|
69
96
|
function isTrackedSubagent(value) {
|
|
@@ -90,6 +117,46 @@ function readTaskPrompt(args) {
|
|
|
90
117
|
}
|
|
91
118
|
return value;
|
|
92
119
|
}
|
|
120
|
+
function delegatedLaunchBindingKey(sessionId, callId) {
|
|
121
|
+
return `${sessionId}::${callId}`;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Read the launch-critical task arguments that decide fresh-exclusive
|
|
125
|
+
* eligibility. Background, resume, and command/subtask launches are excluded;
|
|
126
|
+
* contradictory non-boolean or non-string values also fail closed.
|
|
127
|
+
*/
|
|
128
|
+
function readDelegatedLaunchEligibility(args) {
|
|
129
|
+
if (!args || typeof args !== "object") {
|
|
130
|
+
return { eligible: false, reason: "missing_launch_args" };
|
|
131
|
+
}
|
|
132
|
+
const record = args;
|
|
133
|
+
if ("background" in record && record.background !== false) {
|
|
134
|
+
return { eligible: false, reason: "requested_background" };
|
|
135
|
+
}
|
|
136
|
+
if ("task_id" in record && record.task_id !== undefined) {
|
|
137
|
+
const taskId = record.task_id;
|
|
138
|
+
if (typeof taskId !== "string" || taskId.trim() === "") {
|
|
139
|
+
return { eligible: false, reason: "invalid_task_id" };
|
|
140
|
+
}
|
|
141
|
+
return { eligible: false, reason: "resume_task_id", resumedTaskId: taskId.trim() };
|
|
142
|
+
}
|
|
143
|
+
// The host subtask path always supplies a `command` key (even when its value
|
|
144
|
+
// is undefined), and a slash-command launch always supplies a value, so key
|
|
145
|
+
// presence alone is the reliable command/subtask exclusion.
|
|
146
|
+
if ("command" in record) {
|
|
147
|
+
const command = record.command;
|
|
148
|
+
if (command !== undefined && command !== null && typeof command !== "string") {
|
|
149
|
+
return { eligible: false, reason: "invalid_command" };
|
|
150
|
+
}
|
|
151
|
+
return { eligible: false, reason: "command_or_subtask_launch" };
|
|
152
|
+
}
|
|
153
|
+
return { eligible: true };
|
|
154
|
+
}
|
|
155
|
+
/** Exact current-host wrapper that precedes a returned child task error. */
|
|
156
|
+
function delegatedHostFailurePrefix(childSessionId) {
|
|
157
|
+
return `Subagent failed (task_id: ${childSessionId}): `;
|
|
158
|
+
}
|
|
159
|
+
// END_BLOCK_DELEGATED_FAILURE_BINDING_TYPES
|
|
93
160
|
function canUseWorkflowTools(agentName) {
|
|
94
161
|
return agentName === WORKFLOW_CONTROLLER_AGENT;
|
|
95
162
|
}
|
|
@@ -177,36 +244,347 @@ function createHardStopMessage(options) {
|
|
|
177
244
|
"Inspect work_item_list before retrying.",
|
|
178
245
|
].join("\n");
|
|
179
246
|
}
|
|
180
|
-
export const WorkflowPlugin = async ({ client, directory }) => {
|
|
247
|
+
export const WorkflowPlugin = async ({ client, directory, worktree }) => {
|
|
181
248
|
const vvoc = await loadVvocConfig({ cwd: directory });
|
|
182
249
|
if (!isVvocPluginEnabled(vvoc.config, "workflow"))
|
|
183
250
|
return {};
|
|
184
|
-
const
|
|
251
|
+
const resolvedPolicy = resolveOrchestrationPolicy(vvoc.config);
|
|
252
|
+
const workflowSystemInstruction = getWorkflowSystemInstruction(resolvedPolicy);
|
|
253
|
+
const delegatedProfileActive = resolvedPolicy.profile === "delegated";
|
|
254
|
+
const trustedWorkspaceRoot = worktree || directory;
|
|
185
255
|
// START_BLOCK_PERSISTENCE_SETUP
|
|
186
256
|
// Each session (main or subagent) gets its own isolated store.
|
|
187
257
|
// This prevents subagent tool calls from interfering with the main session's work items.
|
|
258
|
+
// Hydration uses the checked loader: invalid persisted state is tracked so new
|
|
259
|
+
// control transactions and snapshot writes fail closed instead of silently
|
|
260
|
+
// resetting a malformed run.
|
|
188
261
|
const stores = new Map();
|
|
262
|
+
const invalidHydrationSessions = new Set();
|
|
189
263
|
function getOrCreateStore(sessionId) {
|
|
190
264
|
let store = stores.get(sessionId);
|
|
191
265
|
if (!store) {
|
|
192
|
-
const hydrated =
|
|
193
|
-
|
|
266
|
+
const hydrated = hydrateWorkflowStateChecked(sessionId);
|
|
267
|
+
if (hydrated.status === "valid") {
|
|
268
|
+
store = createWorkItemStore(hydrated.data);
|
|
269
|
+
// A freshly hydrated session crossed a process-restart boundary: any
|
|
270
|
+
// persisted in-flight delegated attempt can never receive its host
|
|
271
|
+
// callback again, so reclaim it without consuming the attempt budget.
|
|
272
|
+
const reclaimed = revertInFlightDelegatedLaunches(store.getStoreData(), sessionId);
|
|
273
|
+
if (reclaimed.reverted > 0) {
|
|
274
|
+
void client.app
|
|
275
|
+
.log({
|
|
276
|
+
body: {
|
|
277
|
+
service: "workflow",
|
|
278
|
+
level: "info",
|
|
279
|
+
message: "[workflow][hydration][BLOCK_RECLAIM_ATTEMPTS] orphaned in-flight delegated attempts reverted",
|
|
280
|
+
extra: { sessionID: sessionId, workItemIds: reclaimed.workItemIds },
|
|
281
|
+
},
|
|
282
|
+
})
|
|
283
|
+
.catch(() => undefined);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
if (hydrated.status === "invalid") {
|
|
288
|
+
invalidHydrationSessions.add(sessionId);
|
|
289
|
+
}
|
|
290
|
+
store = createWorkItemStore();
|
|
291
|
+
}
|
|
194
292
|
stores.set(sessionId, store);
|
|
195
293
|
}
|
|
196
294
|
return store;
|
|
197
295
|
}
|
|
198
296
|
function snapshotSession(sessionId) {
|
|
199
297
|
const store = stores.get(sessionId);
|
|
200
|
-
if (store)
|
|
201
|
-
|
|
202
|
-
|
|
298
|
+
if (!store)
|
|
299
|
+
return { ok: true };
|
|
300
|
+
if (invalidHydrationSessions.has(sessionId)) {
|
|
301
|
+
return {
|
|
302
|
+
ok: false,
|
|
303
|
+
error: `persisted workflow state for session ${sessionId} is invalid; refusing to overwrite it`,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
const result = snapshotWorkflowStateChecked(sessionId, store.getStoreData());
|
|
307
|
+
return result.ok ? { ok: true } : { ok: false, error: result.error };
|
|
308
|
+
}
|
|
309
|
+
// END_BLOCK_PERSISTENCE_SETUP
|
|
310
|
+
// START_BLOCK_DELEGATED_FAILURE_BINDING
|
|
311
|
+
// Live host-call bindings let the plugin consume confirmed host-terminal
|
|
312
|
+
// failures of foreground vv-implementer tasks without hydrating a store from
|
|
313
|
+
// an event. Every exclusion is sticky: once a binding is tainted it can never
|
|
314
|
+
// authorize a failure transition.
|
|
315
|
+
const delegatedLaunchBindings = new Map();
|
|
316
|
+
const resumedChildSessions = new Set();
|
|
317
|
+
const childPromptMessageIds = new Map();
|
|
318
|
+
function taintDelegatedLaunchBinding(binding, reason) {
|
|
319
|
+
if (binding.eligible) {
|
|
320
|
+
binding.eligible = false;
|
|
321
|
+
binding.ineligibleReason = reason;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function taintBindingsForChild(childSessionId, reason) {
|
|
325
|
+
for (const binding of delegatedLaunchBindings.values()) {
|
|
326
|
+
if (binding.childSessionId === childSessionId)
|
|
327
|
+
taintDelegatedLaunchBinding(binding, reason);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Track distinct user-prompt message ids per session: a child session
|
|
332
|
+
* prompted with more than one distinct message is shared or resumed, so it
|
|
333
|
+
* can no longer be a fresh-exclusive child.
|
|
334
|
+
*/
|
|
335
|
+
function observeChildPrompt(sessionId, messageId) {
|
|
336
|
+
if (!messageId)
|
|
337
|
+
return;
|
|
338
|
+
let messageIds = childPromptMessageIds.get(sessionId);
|
|
339
|
+
if (!messageIds) {
|
|
340
|
+
messageIds = new Set();
|
|
341
|
+
childPromptMessageIds.set(sessionId, messageIds);
|
|
342
|
+
}
|
|
343
|
+
if (messageIds.has(messageId))
|
|
344
|
+
return;
|
|
345
|
+
messageIds.add(messageId);
|
|
346
|
+
if (messageIds.size > 1) {
|
|
347
|
+
resumedChildSessions.add(sessionId);
|
|
348
|
+
taintBindingsForChild(sessionId, "child_reprompted");
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function observeTaskLaunchArgs(args) {
|
|
352
|
+
const launch = readDelegatedLaunchEligibility(args);
|
|
353
|
+
if (!launch.resumedTaskId)
|
|
354
|
+
return;
|
|
355
|
+
resumedChildSessions.add(launch.resumedTaskId);
|
|
356
|
+
taintBindingsForChild(launch.resumedTaskId, "child_resumed");
|
|
357
|
+
}
|
|
358
|
+
function observeAfterHookEntry(sessionId, callId) {
|
|
359
|
+
const binding = delegatedLaunchBindings.get(delegatedLaunchBindingKey(sessionId, callId));
|
|
360
|
+
if (binding) {
|
|
361
|
+
binding.afterHookEntered = true;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
function observeAfterHookMetadata(sessionId, callId, metadata) {
|
|
365
|
+
const binding = delegatedLaunchBindings.get(delegatedLaunchBindingKey(sessionId, callId));
|
|
366
|
+
if (!binding || !metadata || typeof metadata !== "object")
|
|
367
|
+
return;
|
|
368
|
+
const record = metadata;
|
|
369
|
+
const child = record.sessionId;
|
|
370
|
+
if (typeof child === "string" && child.trim() !== "") {
|
|
371
|
+
if (binding.childSessionId === undefined) {
|
|
372
|
+
binding.childSessionId = child;
|
|
203
373
|
}
|
|
204
|
-
|
|
205
|
-
|
|
374
|
+
else if (binding.childSessionId !== child) {
|
|
375
|
+
taintDelegatedLaunchBinding(binding, "metadata_child_replaced");
|
|
206
376
|
}
|
|
207
377
|
}
|
|
378
|
+
if (record.background === true)
|
|
379
|
+
taintDelegatedLaunchBinding(binding, "metadata_background");
|
|
380
|
+
if (typeof record.jobId === "string" && record.jobId !== "") {
|
|
381
|
+
taintDelegatedLaunchBinding(binding, "metadata_job");
|
|
382
|
+
}
|
|
383
|
+
if (record.interrupted === true)
|
|
384
|
+
taintDelegatedLaunchBinding(binding, "metadata_interrupted");
|
|
208
385
|
}
|
|
209
|
-
|
|
386
|
+
/**
|
|
387
|
+
* Stage the failed-attempt transition, persist a checked snapshot of the
|
|
388
|
+
* staged state, and only then commit it in memory. There is no async yield
|
|
389
|
+
* between the final identity/exclusion check and the commit; a checked write
|
|
390
|
+
* failure leaves the live in-flight attempt (and retry block) intact.
|
|
391
|
+
*/
|
|
392
|
+
function commitDelegatedLaunchFailure(sessionId, binding, failureExcerpt) {
|
|
393
|
+
const liveStore = stores.get(sessionId);
|
|
394
|
+
if (!liveStore || invalidHydrationSessions.has(sessionId))
|
|
395
|
+
return;
|
|
396
|
+
const liveData = liveStore.getStoreData();
|
|
397
|
+
const stagedStore = createWorkItemStore(liveData);
|
|
398
|
+
const applied = applyDelegatedLaunchFailure(stagedStore, {
|
|
399
|
+
sessionId,
|
|
400
|
+
workItemId: binding.workItemId,
|
|
401
|
+
callId: binding.callId,
|
|
402
|
+
failureExcerpt,
|
|
403
|
+
});
|
|
404
|
+
if (!applied.ok) {
|
|
405
|
+
// Stale, duplicate, or already-transitioned: settle without mutation.
|
|
406
|
+
binding.settled = true;
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const stagedData = stagedStore.getStoreData();
|
|
410
|
+
const persisted = snapshotWorkflowStateChecked(sessionId, stagedData);
|
|
411
|
+
if (!persisted.ok) {
|
|
412
|
+
// Keep the live in-flight attempt and evidence so an unpersisted retry
|
|
413
|
+
// is never authorized; a later authoritative event can retry safely.
|
|
414
|
+
void client.app
|
|
415
|
+
.log({
|
|
416
|
+
body: {
|
|
417
|
+
service: "workflow",
|
|
418
|
+
level: "error",
|
|
419
|
+
message: "[workflow][delegatedFailure][BLOCK_DELEGATED_FAILURE] persistence failed",
|
|
420
|
+
extra: {
|
|
421
|
+
sessionID: sessionId,
|
|
422
|
+
workItemId: binding.workItemId,
|
|
423
|
+
attempt: binding.attempt,
|
|
424
|
+
error: persisted.error.slice(0, 300),
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
})
|
|
428
|
+
.catch(() => undefined);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const lookupKey = createRecordLookupKey(sessionId, binding.workItemId);
|
|
432
|
+
const committed = stagedData.records.get(lookupKey);
|
|
433
|
+
if (!committed)
|
|
434
|
+
return;
|
|
435
|
+
liveData.records.set(lookupKey, committed);
|
|
436
|
+
binding.settled = true;
|
|
437
|
+
void client.app
|
|
438
|
+
.log({
|
|
439
|
+
body: {
|
|
440
|
+
service: "workflow",
|
|
441
|
+
level: "warn",
|
|
442
|
+
message: "[workflow][delegatedFailure][BLOCK_DELEGATED_FAILURE] host-terminal launch failure recorded",
|
|
443
|
+
extra: {
|
|
444
|
+
sessionID: sessionId,
|
|
445
|
+
workItemId: binding.workItemId,
|
|
446
|
+
attempt: applied.attempt,
|
|
447
|
+
consumedAttempts: applied.consumedAttempts,
|
|
448
|
+
attemptBudget: applied.attemptBudget,
|
|
449
|
+
retryAllowed: applied.retryAllowed,
|
|
450
|
+
},
|
|
451
|
+
},
|
|
452
|
+
})
|
|
453
|
+
.catch(() => undefined);
|
|
454
|
+
}
|
|
455
|
+
function handleDelegatedPartUpdated(properties) {
|
|
456
|
+
const part = properties.part;
|
|
457
|
+
if (!part || typeof part !== "object")
|
|
458
|
+
return;
|
|
459
|
+
if (part.type !== "tool" || part.tool !== "task")
|
|
460
|
+
return;
|
|
461
|
+
const parentSessionId = typeof part.sessionID === "string" ? part.sessionID : undefined;
|
|
462
|
+
const callId = typeof part.callID === "string" ? part.callID : undefined;
|
|
463
|
+
if (!parentSessionId || !callId)
|
|
464
|
+
return;
|
|
465
|
+
const binding = delegatedLaunchBindings.get(delegatedLaunchBindingKey(parentSessionId, callId));
|
|
466
|
+
if (!binding)
|
|
467
|
+
return;
|
|
468
|
+
const state = part.state;
|
|
469
|
+
if (!state || typeof state !== "object")
|
|
470
|
+
return;
|
|
471
|
+
const isError = state.status === "error";
|
|
472
|
+
const metadata = state.metadata && typeof state.metadata === "object"
|
|
473
|
+
? state.metadata
|
|
474
|
+
: undefined;
|
|
475
|
+
if (!metadata) {
|
|
476
|
+
// A non-error part may simply not carry metadata yet; an error without
|
|
477
|
+
// metadata can never be bound and is latched blocked to stay fail-closed.
|
|
478
|
+
if (isError)
|
|
479
|
+
taintDelegatedLaunchBinding(binding, "metadata_missing_on_error");
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const metaParent = metadata.parentSessionId;
|
|
483
|
+
const metaChild = metadata.sessionId;
|
|
484
|
+
if (metaParent !== parentSessionId) {
|
|
485
|
+
taintDelegatedLaunchBinding(binding, "metadata_parent_mismatch");
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (typeof metaChild !== "string" || metaChild.trim() === "") {
|
|
489
|
+
taintDelegatedLaunchBinding(binding, "metadata_child_missing");
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
if (binding.childSessionId === undefined) {
|
|
493
|
+
binding.childSessionId = metaChild;
|
|
494
|
+
}
|
|
495
|
+
else if (binding.childSessionId !== metaChild) {
|
|
496
|
+
taintDelegatedLaunchBinding(binding, "metadata_child_replaced");
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
if (metadata.background === true) {
|
|
500
|
+
taintDelegatedLaunchBinding(binding, "metadata_background");
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (typeof metadata.jobId === "string" && metadata.jobId !== "") {
|
|
504
|
+
taintDelegatedLaunchBinding(binding, "metadata_job");
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (metadata.interrupted === true) {
|
|
508
|
+
taintDelegatedLaunchBinding(binding, "metadata_interrupted");
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (resumedChildSessions.has(metaChild)) {
|
|
512
|
+
taintDelegatedLaunchBinding(binding, "child_resumed");
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (binding.afterHookEntered || !binding.eligible || binding.settled)
|
|
516
|
+
return;
|
|
517
|
+
if (!isError)
|
|
518
|
+
return;
|
|
519
|
+
const error = state.error;
|
|
520
|
+
if (typeof error !== "string")
|
|
521
|
+
return;
|
|
522
|
+
if (!error.startsWith(delegatedHostFailurePrefix(metaChild)))
|
|
523
|
+
return;
|
|
524
|
+
const failureExcerpt = createWorkflowResultExcerpt({
|
|
525
|
+
text: error,
|
|
526
|
+
source: "normalized_output",
|
|
527
|
+
});
|
|
528
|
+
if (!failureExcerpt)
|
|
529
|
+
return;
|
|
530
|
+
if (!stores.has(parentSessionId) || invalidHydrationSessions.has(parentSessionId))
|
|
531
|
+
return;
|
|
532
|
+
commitDelegatedLaunchFailure(parentSessionId, binding, failureExcerpt);
|
|
533
|
+
}
|
|
534
|
+
// END_BLOCK_DELEGATED_FAILURE_BINDING
|
|
535
|
+
// START_BLOCK_DELEGATED_AUTHORIZATION
|
|
536
|
+
// New control mutations require the primary vv-controller session: the calling
|
|
537
|
+
// agent must be vv-controller, the session must be a root session (no
|
|
538
|
+
// parentID), and the ToolContext workspace must match the plugin's trusted
|
|
539
|
+
// directory/worktree. None of this identity comes from tool arguments.
|
|
540
|
+
async function assertPrimaryControllerMutation(agent, sessionId, contextWorkspace, toolName) {
|
|
541
|
+
if (!canUseWorkflowTools(agent)) {
|
|
542
|
+
throw new Error(`CONTROL_DENIED: ${toolName} is only available to ${WORKFLOW_CONTROLLER_AGENT} sessions. Current agent: ${agent?.trim() || "unknown-agent"}.`);
|
|
543
|
+
}
|
|
544
|
+
if (contextWorkspace.worktree !== undefined &&
|
|
545
|
+
contextWorkspace.worktree !== worktree &&
|
|
546
|
+
contextWorkspace.worktree !== trustedWorkspaceRoot) {
|
|
547
|
+
throw new Error(`CONTROL_DENIED: ${toolName} workspace ${contextWorkspace.worktree} does not match the trusted plugin workspace.`);
|
|
548
|
+
}
|
|
549
|
+
if (contextWorkspace.worktree === undefined &&
|
|
550
|
+
contextWorkspace.directory !== undefined &&
|
|
551
|
+
contextWorkspace.directory !== directory) {
|
|
552
|
+
throw new Error(`CONTROL_DENIED: ${toolName} directory ${contextWorkspace.directory} does not match the trusted plugin directory.`);
|
|
553
|
+
}
|
|
554
|
+
if (invalidHydrationSessions.has(sessionId)) {
|
|
555
|
+
throw new Error(`CONTROL_DENIED: persisted workflow state for session ${sessionId} is invalid; resolve or remove it before new control mutations.`);
|
|
556
|
+
}
|
|
557
|
+
let parentID;
|
|
558
|
+
try {
|
|
559
|
+
const response = await client.session.get({ path: { id: sessionId } });
|
|
560
|
+
if (!response.data) {
|
|
561
|
+
throw new Error("missing session payload");
|
|
562
|
+
}
|
|
563
|
+
parentID = response.data.parentID;
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
throw new Error(`CONTROL_DENIED: ${toolName} requires root-session identity for ${sessionId}, which could not be verified: ${error.message}`);
|
|
567
|
+
}
|
|
568
|
+
if (parentID !== undefined && parentID !== null && parentID !== "") {
|
|
569
|
+
throw new Error(`CONTROL_DENIED: ${toolName} may only run in the root session; session ${sessionId} is a child of ${parentID}.`);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
// END_BLOCK_DELEGATED_AUTHORIZATION
|
|
573
|
+
// START_BLOCK_CHECKPOINT_LINKAGE
|
|
574
|
+
/** Find the in-flight checkpoint generation whose linked review item matches. */
|
|
575
|
+
function findCheckpointByReviewItem(sessionId, reviewWorkItemId) {
|
|
576
|
+
for (const run of getOrCreateStore(sessionId).getStoreData().planRuns.values()) {
|
|
577
|
+
if (run.sessionId !== sessionId)
|
|
578
|
+
continue;
|
|
579
|
+
for (const checkpoint of run.checkpoints.values()) {
|
|
580
|
+
if (checkpoint.currentReview?.reviewWorkItemId === reviewWorkItemId) {
|
|
581
|
+
return { run, checkpointId: checkpoint.checkpointId };
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return undefined;
|
|
586
|
+
}
|
|
587
|
+
// END_BLOCK_CHECKPOINT_LINKAGE
|
|
210
588
|
// START_BLOCK_PLUGIN_ENTRY
|
|
211
589
|
// Tool wrappers still need a store reference for description/args shape
|
|
212
590
|
// but execute handlers resolve the right store per-call
|
|
@@ -214,6 +592,8 @@ export const WorkflowPlugin = async ({ client, directory }) => {
|
|
|
214
592
|
const workItemOpenTool = createWorkItemOpenTool(dummyStore);
|
|
215
593
|
const workItemListTool = createWorkItemListTool(dummyStore);
|
|
216
594
|
const workItemCloseTool = createWorkItemCloseTool(dummyStore);
|
|
595
|
+
const workItemDecideTool = createWorkItemDecideTool(dummyStore);
|
|
596
|
+
const workCheckpointTool = createWorkCheckpointTool(dummyStore);
|
|
217
597
|
return {
|
|
218
598
|
tool: {
|
|
219
599
|
work_item_open: tool({
|
|
@@ -224,6 +604,9 @@ export const WorkflowPlugin = async ({ client, directory }) => {
|
|
|
224
604
|
title: z.string(),
|
|
225
605
|
mode: z.string(),
|
|
226
606
|
requiredReviewers: z.array(z.string()),
|
|
607
|
+
writeScope: z.array(z.string()).optional(),
|
|
608
|
+
planRunId: z.string().optional(),
|
|
609
|
+
planTaskId: z.string().optional(),
|
|
227
610
|
})),
|
|
228
611
|
},
|
|
229
612
|
async execute(args, context) {
|
|
@@ -258,11 +641,81 @@ export const WorkflowPlugin = async ({ client, directory }) => {
|
|
|
258
641
|
return stringifyToolOutput(closed);
|
|
259
642
|
},
|
|
260
643
|
}),
|
|
644
|
+
// The delegated control tools are registered only under the delegated
|
|
645
|
+
// startup profile; other profiles keep their current tool-schema footprint.
|
|
646
|
+
...(delegatedProfileActive
|
|
647
|
+
? {
|
|
648
|
+
work_item_decide: tool({
|
|
649
|
+
description: workItemDecideTool.description,
|
|
650
|
+
args: {
|
|
651
|
+
workItemId: z.string(),
|
|
652
|
+
attempt: z.number().int().min(1),
|
|
653
|
+
decision: z.enum(["accept", "request_changes", "rework"]),
|
|
654
|
+
rationale: z.string(),
|
|
655
|
+
evidence: z.array(z.string()),
|
|
656
|
+
concernsDisposition: z.string().optional(),
|
|
657
|
+
runId: z.string().optional(),
|
|
658
|
+
checkpointId: z.string().optional(),
|
|
659
|
+
},
|
|
660
|
+
async execute(args, context) {
|
|
661
|
+
// Resolve the store first so invalid persisted state is detected
|
|
662
|
+
// before the authorization check reports it as a control denial.
|
|
663
|
+
const sessionStore = getOrCreateStore(context.sessionID);
|
|
664
|
+
await assertPrimaryControllerMutation(context.agent, context.sessionID, { directory: context.directory, worktree: context.worktree }, "work_item_decide");
|
|
665
|
+
const decided = workItemDecideTool.execute(args, { sessionId: context.sessionID }, sessionStore);
|
|
666
|
+
if (decided.ok) {
|
|
667
|
+
const persisted = snapshotSession(context.sessionID);
|
|
668
|
+
if (!persisted.ok) {
|
|
669
|
+
throw new Error(`PERSISTENCE_FAILED: decision applied in memory but could not be persisted: ${persisted.error}`);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return stringifyToolOutput(decided);
|
|
673
|
+
},
|
|
674
|
+
}),
|
|
675
|
+
work_checkpoint: tool({
|
|
676
|
+
description: workCheckpointTool.description,
|
|
677
|
+
args: {
|
|
678
|
+
action: z.enum(["register", "start", "verify"]),
|
|
679
|
+
planPath: z.string().optional(),
|
|
680
|
+
runId: z.string().optional(),
|
|
681
|
+
checkpointId: z.string().optional(),
|
|
682
|
+
complete: z.boolean().optional(),
|
|
683
|
+
},
|
|
684
|
+
async execute(args, context) {
|
|
685
|
+
// Resolve the store first so invalid persisted state is detected
|
|
686
|
+
// before the authorization check reports it as a control denial.
|
|
687
|
+
const sessionStore = getOrCreateStore(context.sessionID);
|
|
688
|
+
await assertPrimaryControllerMutation(context.agent, context.sessionID, { directory: context.directory, worktree: context.worktree }, "work_checkpoint");
|
|
689
|
+
const toolContext = {
|
|
690
|
+
sessionId: context.sessionID,
|
|
691
|
+
workspaceRoot: trustedWorkspaceRoot,
|
|
692
|
+
loadPlan: async (planPath, workspaceRoot) => {
|
|
693
|
+
const loaded = await loadApprovedDelegatedPlan({ workspaceRoot, planPath });
|
|
694
|
+
return loaded.ok
|
|
695
|
+
? loaded.plan
|
|
696
|
+
: { loadError: `${loaded.code}: ${loaded.message}` };
|
|
697
|
+
},
|
|
698
|
+
};
|
|
699
|
+
const result = await workCheckpointTool.execute({ ...args }, toolContext, sessionStore);
|
|
700
|
+
if (result.ok) {
|
|
701
|
+
const persisted = snapshotSession(context.sessionID);
|
|
702
|
+
if (!persisted.ok) {
|
|
703
|
+
throw new Error(`PERSISTENCE_FAILED: checkpoint change applied in memory but could not be persisted: ${persisted.error}`);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return stringifyToolOutput(result);
|
|
707
|
+
},
|
|
708
|
+
}),
|
|
709
|
+
}
|
|
710
|
+
: {}),
|
|
261
711
|
},
|
|
262
712
|
"tool.execute.before": async (input, output) => {
|
|
263
713
|
if (input.tool !== "task") {
|
|
264
714
|
return;
|
|
265
715
|
}
|
|
716
|
+
// Observe resume identities from every task launch, including untracked
|
|
717
|
+
// ones, before any early return so a resumed child cannot stay eligible.
|
|
718
|
+
observeTaskLaunchArgs(output.args);
|
|
266
719
|
const subagentType = readTaskSubagentType(output.args);
|
|
267
720
|
if (!isTrackedSubagent(subagentType)) {
|
|
268
721
|
return;
|
|
@@ -362,6 +815,90 @@ export const WorkflowPlugin = async ({ client, directory }) => {
|
|
|
362
815
|
});
|
|
363
816
|
throw new Error(`${INVALID_NEXT_AGENT_MARKER} LAUNCH_REJECTED_INVALID_TRANSITION: ${workItem.workItemId} in state ${workItem.state} only allows ${launched.allowedAgents.join(", ") || "no tracked agent"}. ${launched.message}`);
|
|
364
817
|
}
|
|
818
|
+
// START_BLOCK_DELEGATED_LAUNCH_BINDING
|
|
819
|
+
// Delegated implementer launches consume a callID-bound attempt after the
|
|
820
|
+
// checkpoint barrier and overlapping-write gates pass. Reviewer launches
|
|
821
|
+
// on a checkpoint-linked review item bind their call identity to the
|
|
822
|
+
// current generation.
|
|
823
|
+
if (workItem.mode === "delegated" && subagentType === "vv-implementer") {
|
|
824
|
+
const data = sessionStore.getStoreData();
|
|
825
|
+
const planRunId = workItem.delegated?.planRunId;
|
|
826
|
+
if (planRunId) {
|
|
827
|
+
const run = data.planRuns.get(planRunId);
|
|
828
|
+
const binding = run
|
|
829
|
+
? [...run.tasks.values()].find((task) => task.workItemId === workItem.workItemId)
|
|
830
|
+
: undefined;
|
|
831
|
+
const taskDefinition = run?.definition.tasks.find((task) => task.taskId === binding?.taskId);
|
|
832
|
+
if (run && taskDefinition) {
|
|
833
|
+
const barrier = checkpointBarrierUnsatisfied(data, planRunId, taskDefinition.wave);
|
|
834
|
+
if (barrier.ok && barrier.blockers.length > 0) {
|
|
835
|
+
throw new Error(`LAUNCH_REJECTED_CHECKPOINT_BARRIER: ${workItem.workItemId} is in wave ${taskDefinition.wave} blocked by unpassed checkpoint(s) ${barrier.blockers.join(", ")}.`);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
const overlapping = findOverlappingInFlightReview(data, planRunId, workItem.delegated?.writeScope ?? []);
|
|
839
|
+
if (overlapping) {
|
|
840
|
+
throw new Error(`LAUNCH_REJECTED_OVERLAPPING_REVIEW: declared write scope overlaps checkpoint ${overlapping} currently in review.`);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
const delegatedLaunch = beginDelegatedLaunch(sessionStore, {
|
|
844
|
+
sessionId: input.sessionID,
|
|
845
|
+
workItemId: workItem.workItemId,
|
|
846
|
+
callId: input.callID,
|
|
847
|
+
});
|
|
848
|
+
if (!delegatedLaunch.ok) {
|
|
849
|
+
await client.app.log({
|
|
850
|
+
body: {
|
|
851
|
+
service: "workflow",
|
|
852
|
+
level: "warn",
|
|
853
|
+
message: "[workflow][launchValidation][BLOCK_VALIDATE_LAUNCH] launch rejected",
|
|
854
|
+
extra: {
|
|
855
|
+
sessionID: input.sessionID,
|
|
856
|
+
agent: subagentType,
|
|
857
|
+
workItemId: workItem.workItemId,
|
|
858
|
+
reason: delegatedLaunch.errorCode,
|
|
859
|
+
},
|
|
860
|
+
},
|
|
861
|
+
});
|
|
862
|
+
throw new Error(`LAUNCH_REJECTED_DELEGATED_${delegatedLaunch.errorCode}: ${delegatedLaunch.message}`);
|
|
863
|
+
}
|
|
864
|
+
// Bind the successful launch so a later confirmed host-terminal error
|
|
865
|
+
// for this exact call can be consumed as a failed attempt. Eligibility
|
|
866
|
+
// captures background/resume/command exclusions up front.
|
|
867
|
+
const eligibility = readDelegatedLaunchEligibility(output.args);
|
|
868
|
+
delegatedLaunchBindings.set(delegatedLaunchBindingKey(input.sessionID, input.callID), {
|
|
869
|
+
sessionId: input.sessionID,
|
|
870
|
+
callId: input.callID,
|
|
871
|
+
workItemId: workItem.workItemId,
|
|
872
|
+
attempt: delegatedLaunch.attempt,
|
|
873
|
+
eligible: eligibility.eligible,
|
|
874
|
+
ineligibleReason: eligibility.reason,
|
|
875
|
+
afterHookEntered: false,
|
|
876
|
+
settled: false,
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
else {
|
|
880
|
+
const reviewerRole = getReviewerRoleForAgent(subagentType);
|
|
881
|
+
if (reviewerRole) {
|
|
882
|
+
const linked = findCheckpointByReviewItem(input.sessionID, workItem.workItemId);
|
|
883
|
+
if (linked) {
|
|
884
|
+
const bound = recordCheckpointReviewerLaunch(sessionStore, {
|
|
885
|
+
runId: linked.run.runId,
|
|
886
|
+
checkpointId: linked.checkpointId,
|
|
887
|
+
reviewer: reviewerRole,
|
|
888
|
+
callId: input.callID,
|
|
889
|
+
});
|
|
890
|
+
if (!bound.ok) {
|
|
891
|
+
revertReviewerLaunch(sessionStore, {
|
|
892
|
+
sessionId: input.sessionID,
|
|
893
|
+
workItemId: workItem.workItemId,
|
|
894
|
+
agent: subagentType,
|
|
895
|
+
});
|
|
896
|
+
throw new Error(`LAUNCH_REJECTED_CHECKPOINT_REVIEW: ${bound.errorCode}: ${bound.message}`);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
// END_BLOCK_DELEGATED_LAUNCH_BINDING
|
|
365
902
|
snapshotSession(input.sessionID);
|
|
366
903
|
await client.app.log({
|
|
367
904
|
body: {
|
|
@@ -381,6 +918,13 @@ export const WorkflowPlugin = async ({ client, directory }) => {
|
|
|
381
918
|
});
|
|
382
919
|
},
|
|
383
920
|
"tool.execute.after": async (input, output) => {
|
|
921
|
+
// Latch the ambiguous after-hook path synchronously before any logging,
|
|
922
|
+
// parsing, or repair so a later error event for this call cannot be
|
|
923
|
+
// classified as a terminal launch failure.
|
|
924
|
+
if (input.tool === "task") {
|
|
925
|
+
observeAfterHookEntry(input.sessionID, input.callID);
|
|
926
|
+
observeAfterHookMetadata(input.sessionID, input.callID, output?.metadata);
|
|
927
|
+
}
|
|
384
928
|
if (input.tool !== "task") {
|
|
385
929
|
return;
|
|
386
930
|
}
|
|
@@ -540,6 +1084,99 @@ export const WorkflowPlugin = async ({ client, directory }) => {
|
|
|
540
1084
|
formatResultExcerptForError(resultExcerpt),
|
|
541
1085
|
].join("\n"));
|
|
542
1086
|
}
|
|
1087
|
+
// START_BLOCK_DELEGATED_RESULT_BINDING
|
|
1088
|
+
// Delegated implementer results apply to their matching callID-bound
|
|
1089
|
+
// attempt; reviewer results on checkpoint-linked review items are
|
|
1090
|
+
// recorded into the current generation before the legacy round applies.
|
|
1091
|
+
if (current.mode === "delegated" && subagentType === "vv-implementer") {
|
|
1092
|
+
const appliedDelegated = applyDelegatedResult(sessionStore, {
|
|
1093
|
+
sessionId: input.sessionID,
|
|
1094
|
+
workItemId: parsed.value.workItemId,
|
|
1095
|
+
callId: input.callID,
|
|
1096
|
+
resultStatus: parsed.value.status,
|
|
1097
|
+
resultExcerpt,
|
|
1098
|
+
});
|
|
1099
|
+
if (!appliedDelegated.ok) {
|
|
1100
|
+
await client.app.log({
|
|
1101
|
+
body: {
|
|
1102
|
+
service: "workflow",
|
|
1103
|
+
level: "warn",
|
|
1104
|
+
message: "[workflow][resultParsing][BLOCK_PARSE_RESULT] protocol error",
|
|
1105
|
+
extra: {
|
|
1106
|
+
sessionID: input.sessionID,
|
|
1107
|
+
agent: subagentType,
|
|
1108
|
+
workItemId: parsed.value.workItemId,
|
|
1109
|
+
reason: appliedDelegated.errorCode,
|
|
1110
|
+
},
|
|
1111
|
+
},
|
|
1112
|
+
});
|
|
1113
|
+
throw new Error([
|
|
1114
|
+
`RESULT_PROTOCOL_ERROR: ${appliedDelegated.message}`,
|
|
1115
|
+
formatResultExcerptForError(resultExcerpt),
|
|
1116
|
+
].join("\n"));
|
|
1117
|
+
}
|
|
1118
|
+
await client.app.log({
|
|
1119
|
+
body: {
|
|
1120
|
+
service: "workflow",
|
|
1121
|
+
level: "info",
|
|
1122
|
+
message: "[workflow][stateTransition][BLOCK_TRANSITION_STATE] state transitioned",
|
|
1123
|
+
extra: {
|
|
1124
|
+
sessionID: input.sessionID,
|
|
1125
|
+
agent: subagentType,
|
|
1126
|
+
workItemId: parsed.value.workItemId,
|
|
1127
|
+
fromState: appliedDelegated.fromState,
|
|
1128
|
+
toState: appliedDelegated.toState,
|
|
1129
|
+
mode: "delegated",
|
|
1130
|
+
attempt: appliedDelegated.attempt,
|
|
1131
|
+
},
|
|
1132
|
+
},
|
|
1133
|
+
});
|
|
1134
|
+
snapshotSession(input.sessionID);
|
|
1135
|
+
if (appliedDelegated.record.state === "needs_context" ||
|
|
1136
|
+
appliedDelegated.record.state === "blocked") {
|
|
1137
|
+
throw new Error(createHardStopMessage({
|
|
1138
|
+
record: appliedDelegated.record,
|
|
1139
|
+
triggeringAgent: subagentType,
|
|
1140
|
+
triggeringStatus: parsed.value.status,
|
|
1141
|
+
triggeringExcerpt: resultExcerpt,
|
|
1142
|
+
}));
|
|
1143
|
+
}
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
const reviewerRoleForCheckpoint = getReviewerRoleForAgent(subagentType);
|
|
1147
|
+
if (reviewerRoleForCheckpoint) {
|
|
1148
|
+
const linked = findCheckpointByReviewItem(input.sessionID, parsed.value.workItemId);
|
|
1149
|
+
if (linked) {
|
|
1150
|
+
const recorded = recordCheckpointReviewerResult(sessionStore, {
|
|
1151
|
+
runId: linked.run.runId,
|
|
1152
|
+
checkpointId: linked.checkpointId,
|
|
1153
|
+
reviewer: reviewerRoleForCheckpoint,
|
|
1154
|
+
callId: input.callID,
|
|
1155
|
+
status: parsed.value.status,
|
|
1156
|
+
});
|
|
1157
|
+
if (!recorded.ok) {
|
|
1158
|
+
await client.app.log({
|
|
1159
|
+
body: {
|
|
1160
|
+
service: "workflow",
|
|
1161
|
+
level: "warn",
|
|
1162
|
+
message: "[workflow][resultParsing][BLOCK_PARSE_RESULT] protocol error",
|
|
1163
|
+
extra: {
|
|
1164
|
+
sessionID: input.sessionID,
|
|
1165
|
+
agent: subagentType,
|
|
1166
|
+
workItemId: parsed.value.workItemId,
|
|
1167
|
+
reason: recorded.errorCode,
|
|
1168
|
+
},
|
|
1169
|
+
},
|
|
1170
|
+
});
|
|
1171
|
+
revertLaunch();
|
|
1172
|
+
throw new Error([
|
|
1173
|
+
`RESULT_PROTOCOL_ERROR: checkpoint ${linked.checkpointId} rejected the reviewer result: ${recorded.message}`,
|
|
1174
|
+
formatResultExcerptForError(resultExcerpt),
|
|
1175
|
+
].join("\n"));
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
// END_BLOCK_DELEGATED_RESULT_BINDING
|
|
543
1180
|
const applied = applyTrackedResult(sessionStore, {
|
|
544
1181
|
sessionId: input.sessionID,
|
|
545
1182
|
workItemId: parsed.value.workItemId,
|
|
@@ -601,8 +1238,26 @@ export const WorkflowPlugin = async ({ client, directory }) => {
|
|
|
601
1238
|
output.message.system = appendSystemInstruction(output.message.system, workflowSystemInstruction);
|
|
602
1239
|
},
|
|
603
1240
|
event: async (input) => {
|
|
604
|
-
const
|
|
605
|
-
const
|
|
1241
|
+
const event = input.event;
|
|
1242
|
+
const eventType = event.type;
|
|
1243
|
+
const properties = event.properties ?? {};
|
|
1244
|
+
// message.part.updated identifies its session through properties.part,
|
|
1245
|
+
// not properties.sessionID, and must never hydrate a store.
|
|
1246
|
+
if (eventType === "message.part.updated") {
|
|
1247
|
+
handleDelegatedPartUpdated(properties);
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
// A child session that receives more than one user prompt has been
|
|
1251
|
+
// resumed or re-prompted and is no longer a fresh-exclusive child.
|
|
1252
|
+
if (eventType === "message.updated") {
|
|
1253
|
+
const messageInfo = properties.info;
|
|
1254
|
+
if (messageInfo &&
|
|
1255
|
+
messageInfo.role === "user" &&
|
|
1256
|
+
typeof messageInfo.sessionID === "string") {
|
|
1257
|
+
observeChildPrompt(messageInfo.sessionID, typeof messageInfo.id === "string" ? messageInfo.id : undefined);
|
|
1258
|
+
}
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
606
1261
|
const info = properties.info;
|
|
607
1262
|
const eventSessionId = properties.sessionID ?? info?.id;
|
|
608
1263
|
if (!eventSessionId)
|
|
@@ -614,6 +1269,13 @@ export const WorkflowPlugin = async ({ client, directory }) => {
|
|
|
614
1269
|
return;
|
|
615
1270
|
}
|
|
616
1271
|
if (eventType === "session.deleted") {
|
|
1272
|
+
for (const [key, binding] of delegatedLaunchBindings) {
|
|
1273
|
+
if (binding.sessionId === eventSessionId) {
|
|
1274
|
+
delegatedLaunchBindings.delete(key);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
childPromptMessageIds.delete(eventSessionId);
|
|
1278
|
+
resumedChildSessions.delete(eventSessionId);
|
|
617
1279
|
stores.delete(eventSessionId);
|
|
618
1280
|
await deleteWorkflowSessionDir(eventSessionId);
|
|
619
1281
|
await client.app.log({
|