@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
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
// FILE: src/plugins/workflow/delegated.ts
|
|
2
|
+
// VERSION: 1.0.0
|
|
3
|
+
// START_MODULE_CONTRACT
|
|
4
|
+
// PURPOSE: Delegated work-item attempt ledger and explicit controller acceptance decisions with a checkpoint-authorized rework path.
|
|
5
|
+
// SCOPE: Delegated attempt allocation bound to host callIDs with per-session callID uniqueness, call-bound result application, call-bound host-terminal launch failure recorded as a distinct failed attempt (bounded evidence, no budget refund, no state advance), bounded controller decisions (accept / request_changes with rationale, evidence, and concerns disposition), acceptance history with rework revocation, and the two-attempt budget extended only by authorized rework. Pure domain transitions over WorkItemStoreData; no tool, session, or filesystem authorization here.
|
|
6
|
+
// DEPENDS: [src/plugins/workflow/protocol.ts (types), src/plugins/workflow/state.ts (types and shared helpers), src/plugins/workflow/transitions.ts]
|
|
7
|
+
// LINKS: [M-WORKFLOW-DELEGATED, M-WORKFLOW-STATE, M-WORKFLOW-TRANSITIONS, M-WORKFLOW-CHECKPOINTS, V-M-WORKFLOW-DELEGATED]
|
|
8
|
+
// ROLE: RUNTIME
|
|
9
|
+
// MAP_MODE: EXPORTS
|
|
10
|
+
// END_MODULE_CONTRACT
|
|
11
|
+
//
|
|
12
|
+
// START_MODULE_MAP
|
|
13
|
+
// DELEGATED_BASE_ATTEMPTS - Initial controller-directed implementation attempt budget (initial plus one correction).
|
|
14
|
+
// DELEGATED_RATIONALE_MAX_CHARS - Maximum accepted rationale length.
|
|
15
|
+
// DELEGATED_EVIDENCE_MAX_CHARS - Maximum accepted single evidence-reference length.
|
|
16
|
+
// DELEGATED_EVIDENCE_MAX_REFS - Maximum accepted evidence-reference count.
|
|
17
|
+
// DelegatedImplementerStatus - Implementer result statuses recordable on an attempt.
|
|
18
|
+
// DelegatedAttempt - One host-callID-bound implementation attempt.
|
|
19
|
+
// DelegatedAttemptStatus - Attempt lifecycle statuses (in_flight, completed, failed).
|
|
20
|
+
// DelegatedDecisionRecord - One recorded controller decision bound to an attempt.
|
|
21
|
+
// DelegatedAcceptanceRecord - One acceptance with optional later revocation.
|
|
22
|
+
// DelegatedReworkRecord - One checkpoint-authorized rework event.
|
|
23
|
+
// DelegatedWorkItemState - Full delegated-mode record extension.
|
|
24
|
+
// DelegatedWriteScopeValidation - Result of validating declared write-scope text inputs.
|
|
25
|
+
// BeginDelegatedLaunchInput - Delegated launch input with session, item, and host callID.
|
|
26
|
+
// BeginDelegatedLaunchResult - Launch allocation outcome.
|
|
27
|
+
// ApplyDelegatedResultInput - Call-bound delegated result payload.
|
|
28
|
+
// ApplyDelegatedResultResult - Result application outcome.
|
|
29
|
+
// DecideDelegatedWorkItemInput - Controller decision payload with attempt identity and bounded text.
|
|
30
|
+
// DecideDelegatedWorkItemResult - Controller decision outcome.
|
|
31
|
+
// ReworkDelegatedWorkItemInput - Checkpoint-authorized rework request.
|
|
32
|
+
// ReworkDelegatedWorkItemResult - Authorized rework outcome.
|
|
33
|
+
// delegatedAttemptBudget - Allowed attempt count given consumed attempts and rework history.
|
|
34
|
+
// currentDelegatedAcceptance - Currently applicable acceptance for a record.
|
|
35
|
+
// DelegatedDecisionInputValidation - Shape accepted by validateDelegatedDecisionInput.
|
|
36
|
+
// validateDelegatedWriteScope - Validates declared write-scope text into canonical normalized paths.
|
|
37
|
+
// validateDelegatedDecisionInput - Pure validation of decision text bounds.
|
|
38
|
+
// beginDelegatedLaunch - Allocate and bind one implementation attempt to a host callID.
|
|
39
|
+
// beginDelegatedLaunchInStore - Store-level delegated launch allocation.
|
|
40
|
+
// revertInFlightDelegatedLaunches - Reclaim attempts orphaned by a process restart boundary.
|
|
41
|
+
// applyDelegatedResult - Apply one implementer result to its matching in-flight attempt.
|
|
42
|
+
// applyDelegatedResultInStore - Store-level delegated result application.
|
|
43
|
+
// ApplyDelegatedLaunchFailureInput - Call-bound host-terminal launch-failure payload.
|
|
44
|
+
// ApplyDelegatedLaunchFailureResult - Host-terminal launch-failure outcome.
|
|
45
|
+
// applyDelegatedLaunchFailure - Record a confirmed host-terminal launch failure on its matching in-flight attempt.
|
|
46
|
+
// applyDelegatedLaunchFailureInStore - Store-level failed-attempt transition.
|
|
47
|
+
// decideDelegatedWorkItem - Record an explicit controller accept/request_changes decision.
|
|
48
|
+
// decideDelegatedWorkItemInStore - Store-level controller decision.
|
|
49
|
+
// reworkDelegatedWorkItem - Reopen an accepted item under validated checkpoint-failure authorization.
|
|
50
|
+
// reworkDelegatedWorkItemInStore - Store-level guarded rework reducer.
|
|
51
|
+
// END_MODULE_MAP
|
|
52
|
+
//
|
|
53
|
+
// START_CHANGE_SUMMARY
|
|
54
|
+
// LAST_CHANGE: [direct fix inFlightAttempt after failed worker launch - Record confirmed host-terminal launch failures as distinct failed attempts, enforce per-session callID uniqueness across items, and keep failed attempts out of acceptance decisions.]
|
|
55
|
+
// END_CHANGE_SUMMARY
|
|
56
|
+
import { cloneRecord, createRecordLookupKey } from "./state.js";
|
|
57
|
+
import { getAllowedNextAgents } from "./transitions.js";
|
|
58
|
+
import { normalizeDeclaredScopePath } from "../../lib/spec-lint.js";
|
|
59
|
+
// START_BLOCK_DELEGATED_TYPES
|
|
60
|
+
export const DELEGATED_BASE_ATTEMPTS = 2;
|
|
61
|
+
export const DELEGATED_RATIONALE_MAX_CHARS = 2000;
|
|
62
|
+
export const DELEGATED_EVIDENCE_MAX_CHARS = 512;
|
|
63
|
+
export const DELEGATED_EVIDENCE_MAX_REFS = 8;
|
|
64
|
+
// END_BLOCK_DELEGATED_TYPES
|
|
65
|
+
// START_BLOCK_DELEGATED_HELPERS
|
|
66
|
+
function toIsoNow() {
|
|
67
|
+
return new Date().toISOString();
|
|
68
|
+
}
|
|
69
|
+
/** Allowed attempt count: the base budget plus one per authorized rework event. */
|
|
70
|
+
export function delegatedAttemptBudget(state) {
|
|
71
|
+
return DELEGATED_BASE_ATTEMPTS + state.reworkHistory.length;
|
|
72
|
+
}
|
|
73
|
+
/** Currently applicable acceptance for a record: the latest unrevoked acceptance. */
|
|
74
|
+
export function currentDelegatedAcceptance(record) {
|
|
75
|
+
if (!record.delegated)
|
|
76
|
+
return undefined;
|
|
77
|
+
return [...record.delegated.acceptances].reverse().find((acceptance) => !acceptance.revokedAt);
|
|
78
|
+
}
|
|
79
|
+
/** Validate declared write-scope text inputs into canonical normalized paths. */
|
|
80
|
+
export function validateDelegatedWriteScope(paths) {
|
|
81
|
+
if (!Array.isArray(paths) || paths.length === 0) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
message: "writeScope must be a non-empty array of workspace-relative files",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const normalized = [];
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
for (const declared of paths) {
|
|
90
|
+
const result = normalizeDeclaredScopePath(String(declared));
|
|
91
|
+
if (!result.ok) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
message: `writeScope path ${JSON.stringify(String(declared))} is malformed (${result.reason})`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (seen.has(result.path)) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
message: `writeScope path ${JSON.stringify(result.path)} is declared more than once`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
seen.add(result.path);
|
|
104
|
+
normalized.push(result.path);
|
|
105
|
+
}
|
|
106
|
+
return { ok: true, paths: normalized };
|
|
107
|
+
}
|
|
108
|
+
/** Pure validation of decision text bounds shared by the tool layer and domain layer. */
|
|
109
|
+
export function validateDelegatedDecisionInput(input) {
|
|
110
|
+
const rationale = typeof input.rationale === "string" ? input.rationale.trim() : "";
|
|
111
|
+
if (rationale === "") {
|
|
112
|
+
return { ok: false, message: "rationale must be a non-empty string" };
|
|
113
|
+
}
|
|
114
|
+
if (rationale.length > DELEGATED_RATIONALE_MAX_CHARS) {
|
|
115
|
+
return { ok: false, message: `rationale exceeds ${DELEGATED_RATIONALE_MAX_CHARS} characters` };
|
|
116
|
+
}
|
|
117
|
+
if (!Array.isArray(input.evidence) || input.evidence.length === 0) {
|
|
118
|
+
return { ok: false, message: "evidence must be a non-empty array of reference strings" };
|
|
119
|
+
}
|
|
120
|
+
if (input.evidence.length > DELEGATED_EVIDENCE_MAX_REFS) {
|
|
121
|
+
return { ok: false, message: `evidence exceeds ${DELEGATED_EVIDENCE_MAX_REFS} references` };
|
|
122
|
+
}
|
|
123
|
+
for (const reference of input.evidence) {
|
|
124
|
+
const normalized = typeof reference === "string" ? reference.trim() : "";
|
|
125
|
+
if (normalized === "") {
|
|
126
|
+
return { ok: false, message: "evidence references must be non-empty strings" };
|
|
127
|
+
}
|
|
128
|
+
if (normalized.length > DELEGATED_EVIDENCE_MAX_CHARS) {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
message: `evidence reference exceeds ${DELEGATED_EVIDENCE_MAX_CHARS} characters`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (input.concernsDisposition !== undefined) {
|
|
136
|
+
if (typeof input.concernsDisposition !== "string" || input.concernsDisposition.trim() === "") {
|
|
137
|
+
return { ok: false, message: "concernsDisposition must be a non-empty string when provided" };
|
|
138
|
+
}
|
|
139
|
+
if (input.concernsDisposition.trim().length > DELEGATED_RATIONALE_MAX_CHARS) {
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
message: `concernsDisposition exceeds ${DELEGATED_RATIONALE_MAX_CHARS} characters`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { ok: true };
|
|
147
|
+
}
|
|
148
|
+
function findRecord(store, sessionId, workItemId) {
|
|
149
|
+
return store.records.get(createRecordLookupKey(sessionId, workItemId));
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Resolve the single delegated attempt that owns one host callID within a
|
|
153
|
+
* parent session. Ambiguous pre-existing histories fail closed instead of
|
|
154
|
+
* selecting one so an old event can never affect a newer attempt.
|
|
155
|
+
*/
|
|
156
|
+
function findDelegatedCallIdBinding(store, sessionId, callId) {
|
|
157
|
+
const matches = [];
|
|
158
|
+
for (const [lookupKey, record] of store.records) {
|
|
159
|
+
if (record.sessionId !== sessionId || record.mode !== "delegated" || !record.delegated)
|
|
160
|
+
continue;
|
|
161
|
+
for (const attempt of record.delegated.attempts) {
|
|
162
|
+
if (attempt.callId === callId) {
|
|
163
|
+
matches.push({ record, attempt, lookupKey });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (matches.length === 0)
|
|
168
|
+
return { kind: "none" };
|
|
169
|
+
if (matches.length > 1)
|
|
170
|
+
return { kind: "ambiguous" };
|
|
171
|
+
return { kind: "unique", ...matches[0] };
|
|
172
|
+
}
|
|
173
|
+
// START_CONTRACT: beginDelegatedLaunch
|
|
174
|
+
// PURPOSE: Allocate one implementation attempt and bind it to the host tool-call identity.
|
|
175
|
+
// INPUTS: { store: WorkItemStore - backing store, input: BeginDelegatedLaunchInput - session, item, and host callID }
|
|
176
|
+
// OUTPUTS: { BeginDelegatedLaunchResult - allocated attempt or a coded rejection }
|
|
177
|
+
// SIDE_EFFECTS: [Mutates the delegated attempt ledger for valid launches]
|
|
178
|
+
// LINKS: [M-WORKFLOW-DELEGATED, M-WORKFLOW-STATE]
|
|
179
|
+
// END_CONTRACT: beginDelegatedLaunch
|
|
180
|
+
export function beginDelegatedLaunch(store, input) {
|
|
181
|
+
return beginDelegatedLaunchInStore(store.getStoreData(), input);
|
|
182
|
+
}
|
|
183
|
+
export function beginDelegatedLaunchInStore(store, input) {
|
|
184
|
+
const callId = input.callId?.trim() ?? "";
|
|
185
|
+
if (!callId) {
|
|
186
|
+
return { ok: false, errorCode: "INVALID_STATE", message: "callId must be a non-empty string" };
|
|
187
|
+
}
|
|
188
|
+
const existing = findRecord(store, input.sessionId, input.workItemId);
|
|
189
|
+
if (!existing) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
errorCode: "WORK_ITEM_NOT_FOUND",
|
|
193
|
+
message: `WORK_ITEM_NOT_FOUND: ${input.workItemId}`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
if (existing.state === "closed") {
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
errorCode: "WORK_ITEM_ALREADY_CLOSED",
|
|
200
|
+
message: `WORK_ITEM_ALREADY_CLOSED: ${input.workItemId} is already closed`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
if (existing.mode !== "delegated" || !existing.delegated) {
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
errorCode: "WRONG_MODE",
|
|
207
|
+
message: `WRONG_MODE: ${input.workItemId} is ${existing.mode}, not delegated`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (!getAllowedNextAgents(existing).includes("vv-implementer")) {
|
|
211
|
+
return {
|
|
212
|
+
ok: false,
|
|
213
|
+
errorCode: "INVALID_STATE",
|
|
214
|
+
message: `INVALID_STATE: ${input.workItemId} is ${existing.state} and cannot start an implementation attempt`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
const delegated = existing.delegated;
|
|
218
|
+
if (delegated.attempts.some((attempt) => attempt.status === "in_flight")) {
|
|
219
|
+
return {
|
|
220
|
+
ok: false,
|
|
221
|
+
errorCode: "ATTEMPT_IN_FLIGHT",
|
|
222
|
+
message: `ATTEMPT_IN_FLIGHT: ${input.workItemId} already has an in-flight implementation attempt`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
if (delegated.attempts.length >= delegatedAttemptBudget(delegated)) {
|
|
226
|
+
return {
|
|
227
|
+
ok: false,
|
|
228
|
+
errorCode: "ATTEMPTS_EXHAUSTED",
|
|
229
|
+
message: `ATTEMPTS_EXHAUSTED: ${input.workItemId} consumed ${delegated.attempts.length} of ${delegatedAttemptBudget(delegated)} allowed attempts; explicit recovery or checkpoint-authorized rework is required`,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
// A host callID is a single-shot identity within a parent session. Reusing it
|
|
233
|
+
// for a new attempt — in this item or any other — would let a delayed event
|
|
234
|
+
// from the old call affect the new attempt, so fail closed instead.
|
|
235
|
+
const callIdBinding = findDelegatedCallIdBinding(store, input.sessionId, callId);
|
|
236
|
+
if (callIdBinding.kind !== "none") {
|
|
237
|
+
return {
|
|
238
|
+
ok: false,
|
|
239
|
+
errorCode: "CALL_ID_REUSED",
|
|
240
|
+
message: `CALL_ID_REUSED: callID ${callId} is already bound to a delegated attempt in session ${input.sessionId}`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const attemptNumber = delegated.attempts.length + 1;
|
|
244
|
+
const now = toIsoNow();
|
|
245
|
+
const updated = {
|
|
246
|
+
...existing,
|
|
247
|
+
state: "awaiting_implementer",
|
|
248
|
+
delegated: {
|
|
249
|
+
...delegated,
|
|
250
|
+
attempts: [
|
|
251
|
+
...delegated.attempts,
|
|
252
|
+
{ attempt: attemptNumber, callId, launchedAt: now, status: "in_flight" },
|
|
253
|
+
],
|
|
254
|
+
},
|
|
255
|
+
updatedAt: now,
|
|
256
|
+
};
|
|
257
|
+
store.records.set(createRecordLookupKey(input.sessionId, input.workItemId), updated);
|
|
258
|
+
return { ok: true, record: cloneRecord(updated), attempt: attemptNumber, callId };
|
|
259
|
+
}
|
|
260
|
+
const DELEGATED_RESULT_STATUSES = new Set([
|
|
261
|
+
"DONE",
|
|
262
|
+
"DONE_WITH_CONCERNS",
|
|
263
|
+
"NEEDS_CONTEXT",
|
|
264
|
+
"BLOCKED",
|
|
265
|
+
]);
|
|
266
|
+
// START_CONTRACT: applyDelegatedResult
|
|
267
|
+
// PURPOSE: Apply one implementer result to its matching in-flight attempt and move DONE or DONE_WITH_CONCERNS to awaiting_acceptance.
|
|
268
|
+
// INPUTS: { store: WorkItemStore - backing store, input: ApplyDelegatedResultInput - call-bound result payload }
|
|
269
|
+
// OUTPUTS: { ApplyDelegatedResultResult - applied transition or a coded rejection including stale callbacks }
|
|
270
|
+
// SIDE_EFFECTS: [Mutates the matching delegated attempt and the work-item state]
|
|
271
|
+
// LINKS: [M-WORKFLOW-DELEGATED, M-WORKFLOW-STATE]
|
|
272
|
+
// END_CONTRACT: applyDelegatedResult
|
|
273
|
+
export function applyDelegatedResult(store, input) {
|
|
274
|
+
return applyDelegatedResultInStore(store.getStoreData(), input);
|
|
275
|
+
}
|
|
276
|
+
export function applyDelegatedResultInStore(store, input) {
|
|
277
|
+
const existing = findRecord(store, input.sessionId, input.workItemId);
|
|
278
|
+
if (!existing) {
|
|
279
|
+
return {
|
|
280
|
+
ok: false,
|
|
281
|
+
errorCode: "WORK_ITEM_NOT_FOUND",
|
|
282
|
+
message: `WORK_ITEM_NOT_FOUND: no work item ${input.workItemId} for session ${input.sessionId}`,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
if (existing.state === "closed") {
|
|
286
|
+
return {
|
|
287
|
+
ok: false,
|
|
288
|
+
errorCode: "WORK_ITEM_ALREADY_CLOSED",
|
|
289
|
+
message: `WORK_ITEM_ALREADY_CLOSED: ${input.workItemId} is already closed`,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
if (existing.mode !== "delegated" || !existing.delegated) {
|
|
293
|
+
return {
|
|
294
|
+
ok: false,
|
|
295
|
+
errorCode: "WRONG_MODE",
|
|
296
|
+
message: `WRONG_MODE: ${input.workItemId} is ${existing.mode}, not delegated`,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
if (!DELEGATED_RESULT_STATUSES.has(input.resultStatus)) {
|
|
300
|
+
return {
|
|
301
|
+
ok: false,
|
|
302
|
+
errorCode: "INVALID_RESULT_STATUS",
|
|
303
|
+
message: `INVALID_RESULT_STATUS: ${String(input.resultStatus)} is not a delegated implementer status`,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
const inFlight = existing.delegated.attempts.find((attempt) => attempt.status === "in_flight");
|
|
307
|
+
if (!inFlight || inFlight.callId !== input.callId) {
|
|
308
|
+
return {
|
|
309
|
+
ok: false,
|
|
310
|
+
errorCode: "STALE_CALLBACK",
|
|
311
|
+
message: `STALE_CALLBACK: ${input.callId} does not match the in-flight attempt ${inFlight ? inFlight.callId : "(none)"} for ${input.workItemId}`,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
const now = toIsoNow();
|
|
315
|
+
const fromState = existing.state;
|
|
316
|
+
const nextState = input.resultStatus === "DONE" || input.resultStatus === "DONE_WITH_CONCERNS"
|
|
317
|
+
? "awaiting_acceptance"
|
|
318
|
+
: input.resultStatus === "NEEDS_CONTEXT"
|
|
319
|
+
? "needs_context"
|
|
320
|
+
: "blocked";
|
|
321
|
+
const updated = {
|
|
322
|
+
...existing,
|
|
323
|
+
state: nextState,
|
|
324
|
+
...(input.resultExcerpt ? { resultExcerpt: input.resultExcerpt } : {}),
|
|
325
|
+
delegated: {
|
|
326
|
+
...existing.delegated,
|
|
327
|
+
attempts: existing.delegated.attempts.map((attempt) => attempt.attempt === inFlight.attempt
|
|
328
|
+
? {
|
|
329
|
+
...attempt,
|
|
330
|
+
status: "completed",
|
|
331
|
+
resultStatus: input.resultStatus,
|
|
332
|
+
completedAt: now,
|
|
333
|
+
...(input.resultExcerpt ? { resultExcerpt: input.resultExcerpt } : {}),
|
|
334
|
+
}
|
|
335
|
+
: attempt),
|
|
336
|
+
},
|
|
337
|
+
updatedAt: now,
|
|
338
|
+
};
|
|
339
|
+
store.records.set(createRecordLookupKey(input.sessionId, input.workItemId), updated);
|
|
340
|
+
return {
|
|
341
|
+
ok: true,
|
|
342
|
+
record: cloneRecord(updated),
|
|
343
|
+
fromState,
|
|
344
|
+
toState: nextState,
|
|
345
|
+
attempt: inFlight.attempt,
|
|
346
|
+
resultStatus: input.resultStatus,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
// START_CONTRACT: applyDelegatedLaunchFailure
|
|
350
|
+
// PURPOSE: Record a confirmed host-terminal launch failure on its matching in-flight attempt without advancing the item lifecycle.
|
|
351
|
+
// INPUTS: { store: WorkItemStore - backing store, input: ApplyDelegatedLaunchFailureInput - call-bound host wrapper error evidence }
|
|
352
|
+
// OUTPUTS: { ApplyDelegatedLaunchFailureResult - failed attempt with remaining budget or a coded rejection }
|
|
353
|
+
// SIDE_EFFECTS: [Marks the matching in-flight attempt failed and consumes one attempt from the existing budget]
|
|
354
|
+
// LINKS: [M-WORKFLOW-DELEGATED, M-WORKFLOW-STATE]
|
|
355
|
+
// END_CONTRACT: applyDelegatedLaunchFailure
|
|
356
|
+
export function applyDelegatedLaunchFailure(store, input) {
|
|
357
|
+
return applyDelegatedLaunchFailureInStore(store.getStoreData(), input);
|
|
358
|
+
}
|
|
359
|
+
export function applyDelegatedLaunchFailureInStore(store, input) {
|
|
360
|
+
const existing = findRecord(store, input.sessionId, input.workItemId);
|
|
361
|
+
if (!existing) {
|
|
362
|
+
return {
|
|
363
|
+
ok: false,
|
|
364
|
+
errorCode: "WORK_ITEM_NOT_FOUND",
|
|
365
|
+
message: `WORK_ITEM_NOT_FOUND: no work item ${input.workItemId} for session ${input.sessionId}`,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
if (existing.state === "closed") {
|
|
369
|
+
return {
|
|
370
|
+
ok: false,
|
|
371
|
+
errorCode: "WORK_ITEM_ALREADY_CLOSED",
|
|
372
|
+
message: `WORK_ITEM_ALREADY_CLOSED: ${input.workItemId} is already closed`,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
if (existing.mode !== "delegated" || !existing.delegated) {
|
|
376
|
+
return {
|
|
377
|
+
ok: false,
|
|
378
|
+
errorCode: "WRONG_MODE",
|
|
379
|
+
message: `WRONG_MODE: ${input.workItemId} is ${existing.mode}, not delegated`,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
// A host-terminal launch failure is only meaningful while the item is still
|
|
383
|
+
// waiting for its implementer. Any other lifecycle state means a different
|
|
384
|
+
// transition got there first, so fail closed.
|
|
385
|
+
if (existing.state !== "awaiting_implementer") {
|
|
386
|
+
return {
|
|
387
|
+
ok: false,
|
|
388
|
+
errorCode: "INVALID_STATE",
|
|
389
|
+
message: `INVALID_STATE: ${input.workItemId} is ${existing.state}, not awaiting_implementer`,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
const callIdBinding = findDelegatedCallIdBinding(store, input.sessionId, input.callId);
|
|
393
|
+
if (callIdBinding.kind === "ambiguous") {
|
|
394
|
+
return {
|
|
395
|
+
ok: false,
|
|
396
|
+
errorCode: "AMBIGUOUS_CALL_ID",
|
|
397
|
+
message: `AMBIGUOUS_CALL_ID: callID ${input.callId} is bound to more than one delegated attempt in session ${input.sessionId}`,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
if (callIdBinding.kind === "unique" &&
|
|
401
|
+
(callIdBinding.record.workItemId !== input.workItemId ||
|
|
402
|
+
callIdBinding.record.sessionId !== input.sessionId)) {
|
|
403
|
+
return {
|
|
404
|
+
ok: false,
|
|
405
|
+
errorCode: "STALE_CALLBACK",
|
|
406
|
+
message: `STALE_CALLBACK: ${input.callId} is bound to ${callIdBinding.record.workItemId}, not ${input.workItemId}`,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
const inFlight = existing.delegated.attempts.find((attempt) => attempt.status === "in_flight" && attempt.callId === input.callId);
|
|
410
|
+
if (!inFlight) {
|
|
411
|
+
return {
|
|
412
|
+
ok: false,
|
|
413
|
+
errorCode: "STALE_CALLBACK",
|
|
414
|
+
message: `STALE_CALLBACK: ${input.callId} does not match an in-flight attempt for ${input.workItemId}`,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
const now = toIsoNow();
|
|
418
|
+
const updated = {
|
|
419
|
+
...existing,
|
|
420
|
+
// Failed attempts never advance the lifecycle: the item stays waiting at
|
|
421
|
+
// the launch gate so an explicit controller retry can consume the
|
|
422
|
+
// remaining budget, and two failures naturally exhaust it.
|
|
423
|
+
state: "awaiting_implementer",
|
|
424
|
+
delegated: {
|
|
425
|
+
...existing.delegated,
|
|
426
|
+
attempts: existing.delegated.attempts.map((attempt) => attempt.attempt === inFlight.attempt
|
|
427
|
+
? {
|
|
428
|
+
...attempt,
|
|
429
|
+
status: "failed",
|
|
430
|
+
failureExcerpt: input.failureExcerpt,
|
|
431
|
+
completedAt: now,
|
|
432
|
+
}
|
|
433
|
+
: attempt),
|
|
434
|
+
},
|
|
435
|
+
updatedAt: now,
|
|
436
|
+
};
|
|
437
|
+
store.records.set(createRecordLookupKey(input.sessionId, input.workItemId), updated);
|
|
438
|
+
const attemptBudget = delegatedAttemptBudget(updated.delegated);
|
|
439
|
+
return {
|
|
440
|
+
ok: true,
|
|
441
|
+
record: cloneRecord(updated),
|
|
442
|
+
fromState: existing.state,
|
|
443
|
+
toState: "awaiting_implementer",
|
|
444
|
+
attempt: inFlight.attempt,
|
|
445
|
+
consumedAttempts: updated.delegated?.attempts.length ?? 0,
|
|
446
|
+
attemptBudget,
|
|
447
|
+
retryAllowed: (updated.delegated?.attempts.length ?? 0) < attemptBudget,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
// START_CONTRACT: decideDelegatedWorkItem
|
|
451
|
+
// PURPOSE: Record an explicit controller accept or request_changes decision bound to the current completed attempt.
|
|
452
|
+
// INPUTS: { store: WorkItemStore - backing store, input: DecideDelegatedWorkItemInput - decision payload with attempt identity and bounded text }
|
|
453
|
+
// OUTPUTS: { DecideDelegatedWorkItemResult - applied decision or a coded rejection without mutation }
|
|
454
|
+
// SIDE_EFFECTS: [Mutates decision history, acceptance state, and the work-item lifecycle state]
|
|
455
|
+
// LINKS: [M-WORKFLOW-DELEGATED, M-WORKFLOW-STATE]
|
|
456
|
+
// END_CONTRACT: decideDelegatedWorkItem
|
|
457
|
+
export function decideDelegatedWorkItem(store, input) {
|
|
458
|
+
return decideDelegatedWorkItemInStore(store.getStoreData(), input);
|
|
459
|
+
}
|
|
460
|
+
export function decideDelegatedWorkItemInStore(store, input) {
|
|
461
|
+
if (input.decision !== "accept" && input.decision !== "request_changes") {
|
|
462
|
+
return {
|
|
463
|
+
ok: false,
|
|
464
|
+
errorCode: "INVALID_INPUT",
|
|
465
|
+
message: "INVALID_INPUT: decision must be accept or request_changes",
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
const validation = validateDelegatedDecisionInput({
|
|
469
|
+
decision: input.decision,
|
|
470
|
+
rationale: input.rationale,
|
|
471
|
+
evidence: input.evidence,
|
|
472
|
+
concernsDisposition: input.concernsDisposition,
|
|
473
|
+
});
|
|
474
|
+
if (!validation.ok) {
|
|
475
|
+
return {
|
|
476
|
+
ok: false,
|
|
477
|
+
errorCode: "INVALID_INPUT",
|
|
478
|
+
message: `INVALID_INPUT: ${validation.message}`,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
if (!Number.isInteger(input.attempt) || input.attempt < 1) {
|
|
482
|
+
return {
|
|
483
|
+
ok: false,
|
|
484
|
+
errorCode: "INVALID_INPUT",
|
|
485
|
+
message: "INVALID_INPUT: attempt must be a positive integer",
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
const existing = findRecord(store, input.sessionId, input.workItemId);
|
|
489
|
+
if (!existing) {
|
|
490
|
+
return {
|
|
491
|
+
ok: false,
|
|
492
|
+
errorCode: "WORK_ITEM_NOT_FOUND",
|
|
493
|
+
message: `WORK_ITEM_NOT_FOUND: no work item ${input.workItemId} for session ${input.sessionId}`,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
if (existing.state === "closed") {
|
|
497
|
+
return {
|
|
498
|
+
ok: false,
|
|
499
|
+
errorCode: "WORK_ITEM_ALREADY_CLOSED",
|
|
500
|
+
message: `WORK_ITEM_ALREADY_CLOSED: ${input.workItemId} is already closed`,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
if (existing.mode !== "delegated" || !existing.delegated) {
|
|
504
|
+
return {
|
|
505
|
+
ok: false,
|
|
506
|
+
errorCode: "WRONG_MODE",
|
|
507
|
+
message: `WRONG_MODE: ${input.workItemId} is ${existing.mode}; only delegated items receive controller decisions`,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
if (existing.state !== "awaiting_acceptance") {
|
|
511
|
+
return {
|
|
512
|
+
ok: false,
|
|
513
|
+
errorCode: "INVALID_STATE",
|
|
514
|
+
message: `INVALID_STATE: ${input.workItemId} is ${existing.state}, not awaiting_acceptance`,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
const completed = [...existing.delegated.attempts]
|
|
518
|
+
.filter((attempt) => attempt.status === "completed")
|
|
519
|
+
.sort((left, right) => right.attempt - left.attempt)[0];
|
|
520
|
+
if (!completed) {
|
|
521
|
+
return {
|
|
522
|
+
ok: false,
|
|
523
|
+
errorCode: "INVALID_STATE",
|
|
524
|
+
message: `INVALID_STATE: ${input.workItemId} has no completed attempt to decide`,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
if (completed.attempt !== input.attempt) {
|
|
528
|
+
return {
|
|
529
|
+
ok: false,
|
|
530
|
+
errorCode: "INVALID_ATTEMPT",
|
|
531
|
+
message: `INVALID_ATTEMPT: decision targets attempt ${input.attempt} but the current completed attempt is ${completed.attempt}`,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
if (existing.delegated.decisions.some((decision) => decision.attempt === input.attempt)) {
|
|
535
|
+
return {
|
|
536
|
+
ok: false,
|
|
537
|
+
errorCode: "DUPLICATE_DECISION",
|
|
538
|
+
message: `DUPLICATE_DECISION: attempt ${input.attempt} already received a controller decision`,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
const hasConcerns = completed.resultStatus === "DONE_WITH_CONCERNS";
|
|
542
|
+
if (hasConcerns && input.concernsDisposition === undefined) {
|
|
543
|
+
return {
|
|
544
|
+
ok: false,
|
|
545
|
+
errorCode: "CONCERNS_DISPOSITION_REQUIRED",
|
|
546
|
+
message: `CONCERNS_DISPOSITION_REQUIRED: attempt ${input.attempt} completed DONE_WITH_CONCERNS and requires an explicit disposition`,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
if (!hasConcerns && input.concernsDisposition !== undefined) {
|
|
550
|
+
return {
|
|
551
|
+
ok: false,
|
|
552
|
+
errorCode: "UNEXPECTED_CONCERNS_DISPOSITION",
|
|
553
|
+
message: `UNEXPECTED_CONCERNS_DISPOSITION: attempt ${input.attempt} completed ${completed.resultStatus}, not DONE_WITH_CONCERNS`,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
const now = toIsoNow();
|
|
557
|
+
const decisionId = `dec-${existing.workItemId}-a${input.attempt}`;
|
|
558
|
+
const decision = {
|
|
559
|
+
decisionId,
|
|
560
|
+
attempt: input.attempt,
|
|
561
|
+
decision: input.decision,
|
|
562
|
+
decidedAt: now,
|
|
563
|
+
rationale: input.rationale.trim(),
|
|
564
|
+
evidence: input.evidence.map((reference) => reference.trim()),
|
|
565
|
+
...(input.concernsDisposition !== undefined
|
|
566
|
+
? { concernsDisposition: input.concernsDisposition.trim() }
|
|
567
|
+
: {}),
|
|
568
|
+
};
|
|
569
|
+
const nextState = input.decision === "accept" ? "ready_to_close" : "awaiting_implementer";
|
|
570
|
+
const updated = {
|
|
571
|
+
...existing,
|
|
572
|
+
state: nextState,
|
|
573
|
+
delegated: {
|
|
574
|
+
...existing.delegated,
|
|
575
|
+
decisions: [...existing.delegated.decisions, decision],
|
|
576
|
+
...(input.decision === "accept"
|
|
577
|
+
? {
|
|
578
|
+
acceptances: [
|
|
579
|
+
...existing.delegated.acceptances,
|
|
580
|
+
{
|
|
581
|
+
attempt: input.attempt,
|
|
582
|
+
decisionId,
|
|
583
|
+
acceptedAt: now,
|
|
584
|
+
rationale: input.rationale.trim(),
|
|
585
|
+
evidence: input.evidence.map((reference) => reference.trim()),
|
|
586
|
+
...(input.concernsDisposition !== undefined
|
|
587
|
+
? { concernsDisposition: input.concernsDisposition.trim() }
|
|
588
|
+
: {}),
|
|
589
|
+
},
|
|
590
|
+
],
|
|
591
|
+
}
|
|
592
|
+
: {}),
|
|
593
|
+
},
|
|
594
|
+
updatedAt: now,
|
|
595
|
+
};
|
|
596
|
+
store.records.set(createRecordLookupKey(input.sessionId, input.workItemId), updated);
|
|
597
|
+
return { ok: true, record: cloneRecord(updated), decision: input.decision, decisionId };
|
|
598
|
+
}
|
|
599
|
+
// START_CONTRACT: reworkDelegatedWorkItem
|
|
600
|
+
// PURPOSE: Reopen an accepted delegated item under validated checkpoint-failure authorization without resetting attempt history.
|
|
601
|
+
// INPUTS: { store: WorkItemStore - backing store, input: ReworkDelegatedWorkItemInput - checkpoint authorization payload }
|
|
602
|
+
// OUTPUTS: { ReworkDelegatedWorkItemResult - reopened record with one extra granted attempt or a coded rejection }
|
|
603
|
+
// SIDE_EFFECTS: [Revokes the current acceptance's applicability, records the rework event, and returns the item to awaiting_implementer]
|
|
604
|
+
// LINKS: [M-WORKFLOW-DELEGATED, M-WORKFLOW-CHECKPOINTS]
|
|
605
|
+
// END_CONTRACT: reworkDelegatedWorkItem
|
|
606
|
+
export function reworkDelegatedWorkItem(store, input) {
|
|
607
|
+
return reworkDelegatedWorkItemInStore(store.getStoreData(), input);
|
|
608
|
+
}
|
|
609
|
+
export function reworkDelegatedWorkItemInStore(store, input) {
|
|
610
|
+
const reason = typeof input.reason === "string" ? input.reason.trim() : "";
|
|
611
|
+
const planRunId = typeof input.planRunId === "string" ? input.planRunId.trim() : "";
|
|
612
|
+
const failedCheckpointId = typeof input.failedCheckpointId === "string" ? input.failedCheckpointId.trim() : "";
|
|
613
|
+
if (!reason || reason.length > DELEGATED_RATIONALE_MAX_CHARS) {
|
|
614
|
+
return {
|
|
615
|
+
ok: false,
|
|
616
|
+
errorCode: "INVALID_INPUT",
|
|
617
|
+
message: `INVALID_INPUT: reason must be non-empty and at most ${DELEGATED_RATIONALE_MAX_CHARS} characters`,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
if (!planRunId || !failedCheckpointId) {
|
|
621
|
+
return {
|
|
622
|
+
ok: false,
|
|
623
|
+
errorCode: "INVALID_INPUT",
|
|
624
|
+
message: "INVALID_INPUT: planRunId and failedCheckpointId must be non-empty",
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
const existing = findRecord(store, input.sessionId, input.workItemId);
|
|
628
|
+
if (!existing) {
|
|
629
|
+
return {
|
|
630
|
+
ok: false,
|
|
631
|
+
errorCode: "WORK_ITEM_NOT_FOUND",
|
|
632
|
+
message: `WORK_ITEM_NOT_FOUND: no work item ${input.workItemId} for session ${input.sessionId}`,
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
if (existing.mode !== "delegated" || !existing.delegated) {
|
|
636
|
+
return {
|
|
637
|
+
ok: false,
|
|
638
|
+
errorCode: "WRONG_MODE",
|
|
639
|
+
message: `WRONG_MODE: ${input.workItemId} is ${existing.mode}, not delegated`,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
if (existing.state === "needs_context" || existing.state === "blocked") {
|
|
643
|
+
return {
|
|
644
|
+
ok: false,
|
|
645
|
+
errorCode: "HARD_STOP_STATE",
|
|
646
|
+
message: `HARD_STOP_STATE: ${input.workItemId} is ${existing.state} and cannot be reopened by rework`,
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
if (existing.state !== "ready_to_close" && existing.state !== "closed") {
|
|
650
|
+
return {
|
|
651
|
+
ok: false,
|
|
652
|
+
errorCode: "INVALID_STATE",
|
|
653
|
+
message: `INVALID_STATE: ${input.workItemId} is ${existing.state}; rework targets accepted items`,
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
const delegated = existing.delegated;
|
|
657
|
+
const current = currentDelegatedAcceptance(existing);
|
|
658
|
+
if (!current || current.revokedAt) {
|
|
659
|
+
return {
|
|
660
|
+
ok: false,
|
|
661
|
+
errorCode: "NOT_ACCEPTED",
|
|
662
|
+
message: `NOT_ACCEPTED: ${input.workItemId} has no currently applicable acceptance to revoke`,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
if (delegated.reworkHistory.some((rework) => rework.authorizedByCheckpoint === failedCheckpointId)) {
|
|
666
|
+
return {
|
|
667
|
+
ok: false,
|
|
668
|
+
errorCode: "ALREADY_REWORKED",
|
|
669
|
+
message: `ALREADY_REWORKED: checkpoint ${failedCheckpointId} already authorized one rework of ${input.workItemId}`,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
if (delegated.planRunId && delegated.planRunId !== planRunId) {
|
|
673
|
+
return {
|
|
674
|
+
ok: false,
|
|
675
|
+
errorCode: "INVALID_INPUT",
|
|
676
|
+
message: `INVALID_INPUT: ${input.workItemId} belongs to plan run ${delegated.planRunId}, not ${planRunId}`,
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
const now = toIsoNow();
|
|
680
|
+
const reworkId = `rw-${existing.workItemId}-${failedCheckpointId}`;
|
|
681
|
+
const rework = {
|
|
682
|
+
reworkId,
|
|
683
|
+
planRunId,
|
|
684
|
+
authorizedByCheckpoint: failedCheckpointId,
|
|
685
|
+
reason,
|
|
686
|
+
reworkedAt: now,
|
|
687
|
+
revokedAcceptanceAttempt: current.attempt,
|
|
688
|
+
};
|
|
689
|
+
const updated = {
|
|
690
|
+
...existing,
|
|
691
|
+
state: "awaiting_implementer",
|
|
692
|
+
closedAt: undefined,
|
|
693
|
+
delegated: {
|
|
694
|
+
...delegated,
|
|
695
|
+
reworkHistory: [...delegated.reworkHistory, rework],
|
|
696
|
+
acceptances: delegated.acceptances.map((acceptance) => acceptance === current
|
|
697
|
+
? { ...acceptance, revokedAt: now, revokedByReworkId: reworkId }
|
|
698
|
+
: acceptance),
|
|
699
|
+
},
|
|
700
|
+
updatedAt: now,
|
|
701
|
+
};
|
|
702
|
+
store.records.set(createRecordLookupKey(input.sessionId, input.workItemId), updated);
|
|
703
|
+
return {
|
|
704
|
+
ok: true,
|
|
705
|
+
record: cloneRecord(updated),
|
|
706
|
+
reworkId,
|
|
707
|
+
grantedAttempts: delegatedAttemptBudget(updated.delegated),
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
// START_CONTRACT: revertInFlightDelegatedLaunches
|
|
711
|
+
// PURPOSE: Reclaim delegated attempts that a process-restart boundary orphaned, restoring their items to a launchable state without consuming budget.
|
|
712
|
+
// INPUTS: { store: WorkItemStoreData - hydrated store data, sessionId: string - owning session }
|
|
713
|
+
// OUTPUTS: { { reverted: number; workItemIds: string[] } - reclaimed attempts and affected items }
|
|
714
|
+
// SIDE_EFFECTS: [Removes never-completed in-flight attempts from the ledger and returns items to awaiting_implementer]
|
|
715
|
+
// LINKS: [M-WORKFLOW-DELEGATED, M-WORKFLOW-PERSISTENCE, M-PLUGIN-WORKFLOW]
|
|
716
|
+
// END_CONTRACT: revertInFlightDelegatedLaunches
|
|
717
|
+
export function revertInFlightDelegatedLaunches(store, sessionId) {
|
|
718
|
+
const reverted = [];
|
|
719
|
+
for (const [lookupKey, record] of store.records) {
|
|
720
|
+
if (record.sessionId !== sessionId || record.mode !== "delegated" || !record.delegated)
|
|
721
|
+
continue;
|
|
722
|
+
const inFlight = record.delegated.attempts.filter((attempt) => attempt.status === "in_flight");
|
|
723
|
+
if (inFlight.length === 0)
|
|
724
|
+
continue;
|
|
725
|
+
// Only the plugin's first store creation for a freshly hydrated session
|
|
726
|
+
// calls this reclamation. A persisted in-flight attempt can never receive
|
|
727
|
+
// its host callback after a restart, so reverting it (without consuming
|
|
728
|
+
// budget — it never completed) is the only non-dead-end recovery. Items
|
|
729
|
+
// stay hard-stopped when they are already blocked or needs_context.
|
|
730
|
+
if (record.state === "needs_context" ||
|
|
731
|
+
record.state === "blocked" ||
|
|
732
|
+
record.state === "closed") {
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
const attempts = record.delegated.attempts.filter((attempt) => attempt.status !== "in_flight");
|
|
736
|
+
store.records.set(lookupKey, {
|
|
737
|
+
...record,
|
|
738
|
+
state: attempts.length > 0 || record.delegated.decisions.length > 0
|
|
739
|
+
? "awaiting_implementer"
|
|
740
|
+
: "open",
|
|
741
|
+
delegated: {
|
|
742
|
+
...record.delegated,
|
|
743
|
+
attempts,
|
|
744
|
+
},
|
|
745
|
+
updatedAt: toIsoNow(),
|
|
746
|
+
});
|
|
747
|
+
reverted.push(record.workItemId);
|
|
748
|
+
}
|
|
749
|
+
return { reverted: reverted.length, workItemIds: reverted };
|
|
750
|
+
}
|
|
751
|
+
//# sourceMappingURL=delegated.js.map
|