@zq-silk/yui 0.12.3 → 0.13.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/dist/cli/commandCatalog.js +2 -2
- package/dist/cli/updatePorts.js +18 -2
- package/dist/cli.js +3 -6
- package/dist/commands/durableJobCommands.js +6 -18
- package/dist/commands/executionAuditCommands.js +1 -1
- package/dist/commands/taskActor.js +35 -61
- package/dist/commands/taskCommands.js +260 -200
- package/dist/commands/taskIntegrationCommands.js +33 -24
- package/dist/commands/taskIntegrationQueueCommands.js +16 -18
- package/dist/commands/taskPublicationCommands.js +0 -4
- package/dist/commands/taskWorkspaceCommands.js +1 -1
- package/dist/context/runContextPack.js +15 -0
- package/dist/controller/fileSchedulerStoreAdapter.js +17 -1
- package/dist/controller/jobClient.js +5 -8
- package/dist/controller/jobControl.js +59 -105
- package/dist/executor/workspacePreflightClassification.js +6 -5
- package/dist/integration/gitIntegrationService.js +3 -3
- package/dist/integration/integrationAttempt.js +13 -9
- package/dist/integration/integrationQueueService.js +7 -7
- package/dist/lifecycle/exactRunTerminalization.js +146 -6
- package/dist/milestone/milestone.js +9 -3
- package/dist/review/deltaRecheck.js +1 -1
- package/dist/review/reviewFinding.js +3 -3
- package/dist/review/reviewRound.js +11 -8
- package/dist/run/rejectedYieldAttempt.js +221 -0
- package/dist/runtime/tmuxAdapters.js +12 -3
- package/dist/scheduler/activeRoleRunDelivery.js +4 -0
- package/dist/storage/migration/productionRegistry.js +9 -0
- package/dist/storage/taskStore.js +9 -8
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +4 -0
- package/skills/yui-operator/SKILL.md +19 -13
|
@@ -11,8 +11,7 @@ import { execSync } from "node:child_process";
|
|
|
11
11
|
import { createHash } from "node:crypto";
|
|
12
12
|
import { resolve, sep } from "node:path";
|
|
13
13
|
import { acknowledgeUnknownDurableJob, createDurableJob, durableJobIdempotencyKey, isDurableJobTerminal, requestDurableJobCancel, retryDurableJobIdempotencyKey } from "../job/durableJob.js";
|
|
14
|
-
import {
|
|
15
|
-
import { agentRunDeliveryReceiptId } from "../run/agentRun.js";
|
|
14
|
+
import { activeLiveRoleAgentSession } from "../executor/agentExecutor.js";
|
|
16
15
|
export function createDurableJobControl(store) {
|
|
17
16
|
return {
|
|
18
17
|
startJob(params, now) {
|
|
@@ -63,7 +62,7 @@ export function createDurableJobControl(store) {
|
|
|
63
62
|
return null;
|
|
64
63
|
// rr8: Bind the cancel request to the caller's managed identity. The
|
|
65
64
|
// same rules as job.start apply, checked against the job's owner.
|
|
66
|
-
assertCallerAuthorized(tx, caller,
|
|
65
|
+
assertCallerAuthorized(tx, caller, taskId);
|
|
67
66
|
const next = requestDurableJobCancel(current, now);
|
|
68
67
|
if (next !== current)
|
|
69
68
|
tx.saveDurableJob(taskId, next);
|
|
@@ -75,11 +74,7 @@ export function createDurableJobControl(store) {
|
|
|
75
74
|
const current = tx.getDurableJob(taskId, jobId);
|
|
76
75
|
if (current === null)
|
|
77
76
|
return null;
|
|
78
|
-
|
|
79
|
-
// caller-key, active-Run, receipt, and Session checks, but explicitly
|
|
80
|
-
// require the Leader role; start/cancel's owner binding intentionally
|
|
81
|
-
// permits a Worker to operate its own Work Item.
|
|
82
|
-
assertCallerAuthorized(tx, caller, current.owner, taskId, { leaderOnly: true });
|
|
77
|
+
assertCallerAuthorized(tx, caller, taskId);
|
|
83
78
|
const next = acknowledgeUnknownDurableJob(current, now);
|
|
84
79
|
if (next !== current)
|
|
85
80
|
tx.saveDurableJob(taskId, next);
|
|
@@ -184,9 +179,9 @@ function validateStartParams(store, params) {
|
|
|
184
179
|
}
|
|
185
180
|
}
|
|
186
181
|
// rr8: Bind the declared owner to the caller's managed identity. A
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
assertCallerAuthorized(store, params.caller, params.
|
|
182
|
+
// Role is not an authorization boundary. Scope and exact managed Session
|
|
183
|
+
// identity are verified independently below.
|
|
184
|
+
assertCallerAuthorized(store, params.caller, params.taskId);
|
|
190
185
|
}
|
|
191
186
|
/**
|
|
192
187
|
* rr8/rr12: Bind the declared job owner to the caller's managed identity. The
|
|
@@ -194,49 +189,70 @@ function validateStartParams(store, params) {
|
|
|
194
189
|
* against durable Run/Session state — a self-reported role or scope is never
|
|
195
190
|
* authority on its own. The rules:
|
|
196
191
|
*
|
|
197
|
-
* - `user` (non-managed):
|
|
198
|
-
*
|
|
199
|
-
* Leader Run. A managed Session that sheds its identity and declares
|
|
200
|
-
* `scope: "user"` without that proof is rejected.
|
|
192
|
+
* - `user` (non-managed): rejected because it has no managed Session identity.
|
|
193
|
+
* - `global`: full Task authority after current Role Session verification.
|
|
201
194
|
* - `task` + mismatched taskId: rejected.
|
|
202
195
|
* - `task` + missing `runId`: rejected (a managed caller must bind to a Run).
|
|
203
196
|
* - `task` + Run not found / not active / `roleName !== role`: rejected — the
|
|
204
197
|
* claimed Role must be the real Role of an active Run.
|
|
205
|
-
* - `task
|
|
206
|
-
* -
|
|
207
|
-
* active-Run + in-flight-receipt + Session check as `job.acknowledge`, so a
|
|
208
|
-
* borrowed or stale Leader Run cannot authorize the request.
|
|
209
|
-
* - `task` + any other Role (Worker etc.): the owner must be `work-item` and
|
|
210
|
-
* the caller's Run must be for that same Work Item.
|
|
198
|
+
* - `task`: full authority inside the matching Task after active Run and
|
|
199
|
+
* per-Session caller-key verification; Role does not narrow it.
|
|
211
200
|
*/
|
|
212
201
|
/**
|
|
213
202
|
* rr13: `job.start`/`job.cancel` caller authorization at the Controller
|
|
214
203
|
* boundary. Every identity claim the Controller can verify from durable state
|
|
215
|
-
* (role
|
|
204
|
+
* (role and runId) is replayable by any client in the
|
|
216
205
|
* same Home that reads state.json. The channel itself is therefore not
|
|
217
206
|
* authenticated by those claims alone. A non-replayable per-Session caller key
|
|
218
207
|
* closes the gap:
|
|
219
208
|
*
|
|
220
209
|
* - `user` scope: **rejected outright** for start/cancel. A bare shell or a
|
|
221
|
-
* managed Session that sheds its identity has no
|
|
222
|
-
*
|
|
210
|
+
* managed Session that sheds its identity has no managed Session binding.
|
|
211
|
+
* - `global` scope: verified against the current global Role Session.
|
|
223
212
|
* - `task` scope: the caller must present `callerKey` — the
|
|
224
213
|
* `YUI_JOB_CALLER_KEY` injected at its native Session launch. The Controller
|
|
225
214
|
* hashes it (SHA-256) and compares against the durable `jobCallerKeyHashes`
|
|
226
215
|
* map for the caller's Role + Agent. No legacy fallback: an absent hash or a
|
|
227
216
|
* mismatched key is UNAUTHORIZED.
|
|
228
217
|
*
|
|
229
|
-
* The existing Run
|
|
230
|
-
*
|
|
231
|
-
* channel boundary before any durable-state lookup.
|
|
218
|
+
* The existing Run binding checks run after the key check, so a forged
|
|
219
|
+
* identity that also lacks the key is rejected at the channel boundary.
|
|
232
220
|
*/
|
|
233
|
-
function assertCallerAuthorized(store, caller,
|
|
221
|
+
function assertCallerAuthorized(store, caller, taskId) {
|
|
234
222
|
if (caller.scope === "user") {
|
|
235
223
|
// rr13: A user-scope caller has no per-Session channel binding. Every
|
|
236
|
-
// durable-state claim it could carry
|
|
224
|
+
// durable-state claim it could carry is replayable
|
|
237
225
|
// by any client in the same Home. Reject outright (fail-closed).
|
|
238
226
|
throw jobControlError("UNAUTHORIZED", "job.start/job.cancel requires a managed Session caller key; user scope is rejected.");
|
|
239
227
|
}
|
|
228
|
+
if (caller.scope === "global") {
|
|
229
|
+
const roleName = caller.role;
|
|
230
|
+
const agentId = caller.agentId;
|
|
231
|
+
const role = roleName === undefined ? null : store.getGlobalRole(roleName);
|
|
232
|
+
const binding = role === null || agentId === undefined
|
|
233
|
+
? undefined
|
|
234
|
+
: role.agentBindings[role.activeAgentId];
|
|
235
|
+
const sessions = roleName === undefined ? null : store.getGlobalRoleSessionSet(roleName);
|
|
236
|
+
const session = activeLiveRoleAgentSession(sessions);
|
|
237
|
+
if (role === null
|
|
238
|
+
|| binding === undefined
|
|
239
|
+
|| agentId === undefined
|
|
240
|
+
|| caller.adapterId === undefined
|
|
241
|
+
|| caller.launchId === undefined
|
|
242
|
+
|| sessions === null
|
|
243
|
+
|| sessions.activeAgentId !== role.activeAgentId
|
|
244
|
+
|| session === null
|
|
245
|
+
|| binding.agentId !== agentId
|
|
246
|
+
|| binding.adapterId !== caller.adapterId
|
|
247
|
+
|| session.agentId !== agentId
|
|
248
|
+
|| session.adapterId !== caller.adapterId
|
|
249
|
+
|| session.launchId !== caller.launchId
|
|
250
|
+
|| (caller.nativeSessionId !== undefined
|
|
251
|
+
&& session.nativeSessionId !== caller.nativeSessionId)) {
|
|
252
|
+
throw jobControlError("UNAUTHORIZED", "DurableJob control requires the current managed global Agent Session.");
|
|
253
|
+
}
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
240
256
|
if (caller.taskId !== taskId) {
|
|
241
257
|
throw jobControlError("UNAUTHORIZED", "A managed Task Session may not start or cancel Jobs for a different Task.");
|
|
242
258
|
}
|
|
@@ -262,29 +278,6 @@ function assertCallerAuthorized(store, caller, owner, taskId, options = {}) {
|
|
|
262
278
|
if (presentedHash !== expectedHash) {
|
|
263
279
|
throw jobControlError("UNAUTHORIZED", "The managed Session caller key does not match the durable hash.");
|
|
264
280
|
}
|
|
265
|
-
if (options.leaderOnly && caller.role !== "leader") {
|
|
266
|
-
throw jobControlError("UNAUTHORIZED", "job.acknowledge requires the current Task Leader Session.");
|
|
267
|
-
}
|
|
268
|
-
if (caller.role === "reviewer") {
|
|
269
|
-
throw jobControlError("UNAUTHORIZED", "A Reviewer may not start or cancel DurableJobs.");
|
|
270
|
-
}
|
|
271
|
-
if (caller.role === "leader") {
|
|
272
|
-
// rr12: Verify the caller is THE current in-flight Task Leader, not a
|
|
273
|
-
// borrowed or stale Leader Run. This mirrors job.acknowledge.
|
|
274
|
-
if (caller.receiptId === undefined) {
|
|
275
|
-
throw jobControlError("UNAUTHORIZED", "A Leader must carry the current in-flight Turn receipt.");
|
|
276
|
-
}
|
|
277
|
-
assertLeaderActionRun(store, taskId, {
|
|
278
|
-
runId: run.id,
|
|
279
|
-
receiptId: caller.receiptId
|
|
280
|
-
});
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
// Worker (or any non-leader, non-reviewer managed role): the owner must be
|
|
284
|
-
// the caller's own Work Item, verified through the caller's Run.
|
|
285
|
-
if (owner.kind !== "work-item" || run.workItemId !== owner.workItemId) {
|
|
286
|
-
throw jobControlError("UNAUTHORIZED", "A managed Worker may only start or cancel Jobs owned by its own Work Item.");
|
|
287
|
-
}
|
|
288
281
|
}
|
|
289
282
|
/**
|
|
290
283
|
* Resolve the managed workspace for the job's owner. Returns null if no
|
|
@@ -331,30 +324,6 @@ function readGitHead(path) {
|
|
|
331
324
|
function isTerminalWorkItemStatus(status) {
|
|
332
325
|
return status === "completed" || status === "failed" || status === "retired";
|
|
333
326
|
}
|
|
334
|
-
/**
|
|
335
|
-
* rr5/f5: Verify that the caller is the current in-flight Task Leader. The
|
|
336
|
-
* runId must identify the active Leader Run and the receiptId must match the
|
|
337
|
-
* Run's current in-flight Turn receipt. This binds the assertion to the
|
|
338
|
-
* Leader identity/session the Controller already tracks — a bare flag or a
|
|
339
|
-
* stale Run/receipt pair is rejected.
|
|
340
|
-
*/
|
|
341
|
-
function assertLeaderActionRun(store, taskId, assertion) {
|
|
342
|
-
const run = store.getActiveAgentRun(taskId, "leader");
|
|
343
|
-
if (run === null || run.status !== "active" || run.id !== assertion.runId) {
|
|
344
|
-
throw jobControlError("UNAUTHORIZED", "job.acknowledge requires the current active Task Leader Run.");
|
|
345
|
-
}
|
|
346
|
-
const expectedReceipt = formatAgentRunReceiptId(taskId, run.id);
|
|
347
|
-
if (assertion.receiptId !== expectedReceipt) {
|
|
348
|
-
throw jobControlError("UNAUTHORIZED", "job.acknowledge Leader receipt does not match the current Run.");
|
|
349
|
-
}
|
|
350
|
-
const sessions = store.getTaskRoleSessionSet(taskId, "leader");
|
|
351
|
-
if (sessions === null
|
|
352
|
-
|| sessions.inFlight === null
|
|
353
|
-
|| sessions.inFlight.runId !== run.id
|
|
354
|
-
|| sessions.inFlight.receiptId !== agentRunDeliveryReceiptId(run)) {
|
|
355
|
-
throw jobControlError("UNAUTHORIZED", "job.acknowledge Leader Run is not in flight.");
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
327
|
/**
|
|
359
328
|
* Strict `job.start` params parsing. Throws CoreApplicationError-shaped errors
|
|
360
329
|
* so the socket layer reports INVALID_PARAMS without leaking internals.
|
|
@@ -430,8 +399,7 @@ export function parseDurableJobCancelParams(value) {
|
|
|
430
399
|
}
|
|
431
400
|
/**
|
|
432
401
|
* rr26: `job.acknowledge` carries the same managed task caller as
|
|
433
|
-
* job.start/job.cancel.
|
|
434
|
-
* Session caller key is rejected by assertCallerAuthorized.
|
|
402
|
+
* job.start/job.cancel.
|
|
435
403
|
*/
|
|
436
404
|
export function parseDurableJobAcknowledgeParams(value) {
|
|
437
405
|
if (typeof value !== "object" || value === null || Array.isArray(value)
|
|
@@ -508,9 +476,8 @@ function parseSteps(value) {
|
|
|
508
476
|
* boundary (fail-closed). A non-managed caller must explicitly resolve to
|
|
509
477
|
* `{scope: "user"}`; the Controller never defaults a missing identity to
|
|
510
478
|
* user scope. A present caller must carry a valid `scope` and may carry
|
|
511
|
-
* optional
|
|
512
|
-
*
|
|
513
|
-
* `assertCallerAuthorized`; parsing only validates the JSON shape.
|
|
479
|
+
* optional managed Session identity fields. The identity is verified against
|
|
480
|
+
* durable state by `assertCallerAuthorized`; parsing only validates the JSON shape.
|
|
514
481
|
*/
|
|
515
482
|
function parseCaller(value) {
|
|
516
483
|
if (value === undefined) {
|
|
@@ -521,14 +488,15 @@ function parseCaller(value) {
|
|
|
521
488
|
}
|
|
522
489
|
const record = value;
|
|
523
490
|
const allowed = new Set([
|
|
524
|
-
"scope", "taskId", "role", "
|
|
491
|
+
"scope", "taskId", "role", "agentId", "adapterId", "launchId", "nativeSessionId",
|
|
492
|
+
"runId", "callerKey"
|
|
525
493
|
]);
|
|
526
494
|
for (const key of Object.keys(record)) {
|
|
527
495
|
if (!allowed.has(key)) {
|
|
528
496
|
throw jobControlError("INVALID_PARAMS", "job caller is invalid.");
|
|
529
497
|
}
|
|
530
498
|
}
|
|
531
|
-
if (record.scope !== "user" && record.scope !== "task") {
|
|
499
|
+
if (record.scope !== "user" && record.scope !== "global" && record.scope !== "task") {
|
|
532
500
|
throw jobControlError("INVALID_PARAMS", "job caller scope is invalid.");
|
|
533
501
|
}
|
|
534
502
|
const optionalId = (key) => {
|
|
@@ -539,9 +507,11 @@ function parseCaller(value) {
|
|
|
539
507
|
};
|
|
540
508
|
const taskId = optionalId("taskId");
|
|
541
509
|
const role = optionalId("role");
|
|
510
|
+
const agentId = optionalId("agentId");
|
|
511
|
+
const adapterId = optionalId("adapterId");
|
|
512
|
+
const launchId = optionalId("launchId");
|
|
513
|
+
const nativeSessionId = optionalId("nativeSessionId");
|
|
542
514
|
const runId = optionalId("runId");
|
|
543
|
-
const receiptId = optionalId("receiptId");
|
|
544
|
-
const leaderAssertion = parseLeaderAssertion(record.leaderAssertion);
|
|
545
515
|
const callerKey = record.callerKey === undefined
|
|
546
516
|
? undefined
|
|
547
517
|
: requiredId(record.callerKey, "job caller callerKey");
|
|
@@ -549,30 +519,14 @@ function parseCaller(value) {
|
|
|
549
519
|
scope: record.scope,
|
|
550
520
|
...(taskId === undefined ? {} : { taskId }),
|
|
551
521
|
...(role === undefined ? {} : { role }),
|
|
522
|
+
...(agentId === undefined ? {} : { agentId }),
|
|
523
|
+
...(adapterId === undefined ? {} : { adapterId }),
|
|
524
|
+
...(launchId === undefined ? {} : { launchId }),
|
|
525
|
+
...(nativeSessionId === undefined ? {} : { nativeSessionId }),
|
|
552
526
|
...(runId === undefined ? {} : { runId }),
|
|
553
|
-
...(receiptId === undefined ? {} : { receiptId }),
|
|
554
|
-
...(leaderAssertion === undefined ? {} : { leaderAssertion }),
|
|
555
527
|
...(callerKey === undefined ? {} : { callerKey })
|
|
556
528
|
};
|
|
557
529
|
}
|
|
558
|
-
/**
|
|
559
|
-
* rr12: Parse a `leaderAssertion` sub-object (`{runId, receiptId}`) carried by
|
|
560
|
-
* a user-scope caller. Returns undefined when absent; throws on a malformed
|
|
561
|
-
* shape.
|
|
562
|
-
*/
|
|
563
|
-
function parseLeaderAssertion(value) {
|
|
564
|
-
if (value === undefined)
|
|
565
|
-
return undefined;
|
|
566
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)
|
|
567
|
-
|| Object.keys(value).length !== 2) {
|
|
568
|
-
throw jobControlError("INVALID_PARAMS", "job caller leaderAssertion must have runId and receiptId.");
|
|
569
|
-
}
|
|
570
|
-
const record = value;
|
|
571
|
-
return {
|
|
572
|
-
runId: requiredId(record.runId, "job caller leaderAssertion runId"),
|
|
573
|
-
receiptId: requiredId(record.receiptId, "job caller leaderAssertion receiptId")
|
|
574
|
-
};
|
|
575
|
-
}
|
|
576
530
|
function parseStringMap(value, label) {
|
|
577
531
|
if (value === undefined)
|
|
578
532
|
return {};
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { isTaskOwnedWorkspace } from "../worktree/managedWorkspace.js";
|
|
1
|
+
import { isTaskOwnedWorkspace, sameManagedWorkspaceIdentity } from "../worktree/managedWorkspace.js";
|
|
3
2
|
function ownerLabel(owner) {
|
|
4
3
|
switch (owner.type) {
|
|
5
4
|
case "task":
|
|
@@ -43,11 +42,13 @@ export function classifyWorkspacePreflight(store, task, roleName, activeRun, ins
|
|
|
43
42
|
};
|
|
44
43
|
}
|
|
45
44
|
// 2. Run snapshot validation: when the active Run carries a workspace
|
|
46
|
-
// snapshot
|
|
47
|
-
//
|
|
45
|
+
// snapshot its launch-stable identity must match the durable
|
|
46
|
+
// ManagedWorkspace. Audit timestamps may change without changing the
|
|
47
|
+
// owner, root, or Project entries authorized for the Run.
|
|
48
48
|
if (activeRun?.workspace !== undefined) {
|
|
49
49
|
const durableRunWorkspace = store.getManagedWorkspace(activeRun.workspace.owner);
|
|
50
|
-
if (durableRunWorkspace === null
|
|
50
|
+
if (durableRunWorkspace === null
|
|
51
|
+
|| !sameManagedWorkspaceIdentity(durableRunWorkspace, activeRun.workspace)) {
|
|
51
52
|
const diff = [];
|
|
52
53
|
if (durableRunWorkspace === null) {
|
|
53
54
|
diff.push(`durable ManagedWorkspace for owner ${JSON.stringify(activeRun.workspace.owner)} is missing`);
|
|
@@ -16,7 +16,7 @@ import { acquireProjectMaintenanceLocks } from "../repository/projectMaintenance
|
|
|
16
16
|
import { taskWorkspaceRefSegment } from "../repository/taskWorkspaceIdentity.js";
|
|
17
17
|
import { FileTaskRuntimeIsolation } from "../runtime/taskRuntimeIsolation.js";
|
|
18
18
|
import { yuiTmuxServerName } from "../tmux/tmuxManager.js";
|
|
19
|
-
import { recordIntegrationCheckJob,
|
|
19
|
+
import { recordIntegrationCheckJob, requireResolutionDecision, updateIntegrationAttempt } from "./integrationAttempt.js";
|
|
20
20
|
import { createManagedWorkspace } from "../worktree/managedWorkspace.js";
|
|
21
21
|
import { ResourceRegistrar } from "../resources/resourceRegistrar.js";
|
|
22
22
|
import { readRuntimeIdentity } from "../release/runtimeRelease.js";
|
|
@@ -228,7 +228,7 @@ export class GitIntegrationService {
|
|
|
228
228
|
}
|
|
229
229
|
catch (error) {
|
|
230
230
|
if (error instanceof RemoteBaselineConflictError) {
|
|
231
|
-
const pending =
|
|
231
|
+
const pending = requireResolutionDecision(current, {
|
|
232
232
|
affectedPaths: error.affectedPaths,
|
|
233
233
|
summary: error.message
|
|
234
234
|
}, this.now());
|
|
@@ -592,7 +592,7 @@ export class GitIntegrationService {
|
|
|
592
592
|
await git(["-C", candidatePath, "cherry-pick", "--skip"]);
|
|
593
593
|
continue;
|
|
594
594
|
}
|
|
595
|
-
const pending =
|
|
595
|
+
const pending = requireResolutionDecision(attempt, {
|
|
596
596
|
affectedPaths,
|
|
597
597
|
summary: `ChangeSet ${changeSetId} conflicts with ${attempt.targetRef}.`
|
|
598
598
|
}, this.now());
|
|
@@ -4,7 +4,7 @@ import { normalizeCheckResult } from "./checkResult.js";
|
|
|
4
4
|
export function createIntegrationAttempt(input, now) {
|
|
5
5
|
const timestamp = now.toISOString();
|
|
6
6
|
return validateIntegrationAttempt({
|
|
7
|
-
schemaVersion:
|
|
7
|
+
schemaVersion: 5,
|
|
8
8
|
id: input.id,
|
|
9
9
|
taskId: input.taskId,
|
|
10
10
|
projectId: input.projectId,
|
|
@@ -37,7 +37,7 @@ export function recordIntegrationCheckJob(attempt, jobId, now) {
|
|
|
37
37
|
updatedAt: now.toISOString()
|
|
38
38
|
});
|
|
39
39
|
}
|
|
40
|
-
export function
|
|
40
|
+
export function requireResolutionDecision(attempt, report, now) {
|
|
41
41
|
validateIntegrationAttempt(attempt);
|
|
42
42
|
const continuingAfterResolution = attempt.status === "blocked"
|
|
43
43
|
&& attempt.resolution?.action === "manual-resolution";
|
|
@@ -52,7 +52,7 @@ export function requireLeaderDecision(attempt, report, now) {
|
|
|
52
52
|
updatedAt: now.toISOString()
|
|
53
53
|
});
|
|
54
54
|
}
|
|
55
|
-
export function recordResolutionDecision(attempt, decision, now) {
|
|
55
|
+
export function recordResolutionDecision(attempt, decision, decidedBy, now) {
|
|
56
56
|
validateIntegrationAttempt(attempt);
|
|
57
57
|
if (attempt.status !== "blocked" || attempt.conflict === undefined) {
|
|
58
58
|
throw new Error("Integration has no pending semantic decision.");
|
|
@@ -67,13 +67,19 @@ export function recordResolutionDecision(attempt, decision, now) {
|
|
|
67
67
|
resolution: {
|
|
68
68
|
action: decision.action,
|
|
69
69
|
rationale: requireText(decision.rationale, "Resolution rationale"),
|
|
70
|
-
decidedBy:
|
|
70
|
+
decidedBy: requireTaskControlActor(decidedBy),
|
|
71
71
|
decidedAt: timestamp
|
|
72
72
|
},
|
|
73
73
|
updatedAt: timestamp,
|
|
74
74
|
...(decision.action === "reject" ? { endedAt: timestamp } : {})
|
|
75
75
|
});
|
|
76
76
|
}
|
|
77
|
+
function requireTaskControlActor(value) {
|
|
78
|
+
if (value !== "user" && value !== "operator" && value !== "leader") {
|
|
79
|
+
throw new Error(`Integration decision actor is invalid: ${String(value)}.`);
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
77
83
|
const TERMINAL_STATUSES = ["committed", "superseded", "failed"];
|
|
78
84
|
export function updateIntegrationAttempt(attempt, patch, now) {
|
|
79
85
|
validateIntegrationAttempt(attempt);
|
|
@@ -117,8 +123,8 @@ export function supersedeIntegration(attempt, reason, now) {
|
|
|
117
123
|
});
|
|
118
124
|
}
|
|
119
125
|
export function validateIntegrationAttempt(attempt) {
|
|
120
|
-
if (attempt.schemaVersion !==
|
|
121
|
-
throw new Error("IntegrationAttempt must use schemaVersion
|
|
126
|
+
if (attempt.schemaVersion !== 5) {
|
|
127
|
+
throw new Error("IntegrationAttempt must use schemaVersion 5.");
|
|
122
128
|
}
|
|
123
129
|
validateTaskRecordReference({
|
|
124
130
|
taskId: attempt.taskId,
|
|
@@ -162,9 +168,7 @@ export function validateIntegrationAttempt(attempt) {
|
|
|
162
168
|
throw new Error(`Resolution action is invalid: ${String(attempt.resolution.action)}.`);
|
|
163
169
|
}
|
|
164
170
|
requireText(attempt.resolution.rationale, "Resolution rationale");
|
|
165
|
-
|
|
166
|
-
throw new Error("Only the Leader may record a ResolutionDecision.");
|
|
167
|
-
}
|
|
171
|
+
requireTaskControlActor(attempt.resolution.decidedBy);
|
|
168
172
|
requireTimestamp(attempt.resolution.decidedAt, "Resolution decidedAt");
|
|
169
173
|
}
|
|
170
174
|
attempt.checks?.forEach(normalizeCheckResult);
|
|
@@ -701,11 +701,11 @@ export async function reconcileIntegrationQueueEntry(store, taskId, entryId, git
|
|
|
701
701
|
}
|
|
702
702
|
/**
|
|
703
703
|
* A conflicted entry may carry a blocked IntegrationAttempt waiting for a
|
|
704
|
-
*
|
|
704
|
+
* Task Agent decision. Requeue or supersede IS that decision — the blocked
|
|
705
705
|
* attempt is abandoned — so resolve it as rejected (terminal `failed`)
|
|
706
706
|
* rather than leaving it to block Task retirement.
|
|
707
707
|
*/
|
|
708
|
-
function resolveBlockedAttempt(store, taskId, entry, decision, now) {
|
|
708
|
+
function resolveBlockedAttempt(store, taskId, entry, decision, decidedBy, now) {
|
|
709
709
|
if (entry.integrationAttemptId === undefined)
|
|
710
710
|
return;
|
|
711
711
|
const attempt = store.getIntegrationAttempt(taskId, entry.integrationAttemptId);
|
|
@@ -714,7 +714,7 @@ function resolveBlockedAttempt(store, taskId, entry, decision, now) {
|
|
|
714
714
|
const rejected = recordResolutionDecision(attempt, {
|
|
715
715
|
action: "reject",
|
|
716
716
|
rationale: `Integration Attempt rejected by queue ${decision}.`
|
|
717
|
-
}, now());
|
|
717
|
+
}, decidedBy, now());
|
|
718
718
|
store.saveIntegrationAttempt(taskId, rejected);
|
|
719
719
|
}
|
|
720
720
|
/**
|
|
@@ -737,7 +737,7 @@ function assertRecoverableAttempt(store, taskId, entry, action) {
|
|
|
737
737
|
}
|
|
738
738
|
}
|
|
739
739
|
/** Retry a conflicted item (for example after a gate failure was fixed). */
|
|
740
|
-
export function requeueIntegrationQueueEntry(store, taskId, entryId, now = () => new Date()) {
|
|
740
|
+
export function requeueIntegrationQueueEntry(store, taskId, entryId, decidedBy, now = () => new Date()) {
|
|
741
741
|
// The committed-Attempt guard, the blocked-Attempt finalization, and the
|
|
742
742
|
// queue write must be atomic: a concurrent manual-resolution continue can
|
|
743
743
|
// commit the Attempt between the guard and the write, leaving the queue
|
|
@@ -749,20 +749,20 @@ export function requeueIntegrationQueueEntry(store, taskId, entryId, now = () =>
|
|
|
749
749
|
throw new Error(`Integration queue entry not found: ${taskId}/${entryId}.`);
|
|
750
750
|
}
|
|
751
751
|
assertRecoverableAttempt(tx, taskId, entry, "requeue");
|
|
752
|
-
resolveBlockedAttempt(tx, taskId, entry, "requeue", now);
|
|
752
|
+
resolveBlockedAttempt(tx, taskId, entry, "requeue", decidedBy, now);
|
|
753
753
|
const waiting = markIntegrationQueueRequeued(entry, now());
|
|
754
754
|
tx.saveIntegrationQueueEntry(taskId, waiting);
|
|
755
755
|
return waiting;
|
|
756
756
|
});
|
|
757
757
|
}
|
|
758
|
-
export function supersedeIntegrationQueueEntry(store, taskId, entryId, reason, now = () => new Date()) {
|
|
758
|
+
export function supersedeIntegrationQueueEntry(store, taskId, entryId, reason, decidedBy, now = () => new Date()) {
|
|
759
759
|
return store.transaction((tx) => {
|
|
760
760
|
const entry = tx.getIntegrationQueueEntry(taskId, entryId);
|
|
761
761
|
if (entry === null) {
|
|
762
762
|
throw new Error(`Integration queue entry not found: ${taskId}/${entryId}.`);
|
|
763
763
|
}
|
|
764
764
|
assertRecoverableAttempt(tx, taskId, entry, "supersede");
|
|
765
|
-
resolveBlockedAttempt(tx, taskId, entry, "supersede", now);
|
|
765
|
+
resolveBlockedAttempt(tx, taskId, entry, "supersede", decidedBy, now);
|
|
766
766
|
const superseded = markIntegrationQueueSuperseded(entry, reason, now());
|
|
767
767
|
tx.saveIntegrationQueueEntry(taskId, superseded);
|
|
768
768
|
return superseded;
|