@zq-silk/yui 2.11.0 → 2.12.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/artifacts/artifactCapability.js +14 -8
- package/dist/artifacts/artifactCommitLock.js +7 -5
- package/dist/artifacts/jobArtifact.js +93 -0
- package/dist/artifacts/jobArtifactTransport.js +13 -0
- package/dist/artifacts/taskArtifactRepository.js +58 -10
- package/dist/cli/commandCatalog.js +45 -14
- package/dist/cli/commandDiscovery.js +1 -1
- package/dist/commands/durableJobCommands.js +31 -6
- package/dist/commands/globalRoleCommands.js +5 -0
- package/dist/commands/scheduleCommands.js +14 -3
- package/dist/commands/taskCommands.js +5 -2
- package/dist/context/taskCatalog.js +6 -1
- package/dist/controlPlaneCli.js +7 -2
- package/dist/controller/agentJobControl.js +391 -0
- package/dist/controller/controller.js +24 -3
- package/dist/controller/fileSchedulerStoreAdapter.js +12 -1
- package/dist/controller/globalInputDelivery.js +8 -1
- package/dist/controller/jobArtifactControl.js +111 -0
- package/dist/controller/jobControl.js +4 -0
- package/dist/controller/jobDefinitionControl.js +191 -19
- package/dist/controller/jobSupervisor.js +83 -3
- package/dist/controller/jobTaskDelegationControl.js +193 -0
- package/dist/controller/runtime.js +7 -1
- package/dist/controller/scheduleControl.js +83 -20
- package/dist/integration/gitIntegrationService.js +2 -0
- package/dist/job/agentJob.js +131 -0
- package/dist/job/agentJobObservation.js +61 -0
- package/dist/job/agentRoleScope.js +33 -0
- package/dist/job/durableJob.js +63 -28
- package/dist/job/jobAssignmentScope.js +1 -0
- package/dist/job/jobBusinessResult.js +72 -0
- package/dist/job/jobCleanupInspection.js +4 -0
- package/dist/job/jobDefinition.js +26 -2
- package/dist/job/jobRunner.js +4 -3
- package/dist/job/projectRefreshRunner.js +54 -18
- package/dist/job/projectSync.js +162 -0
- package/dist/job/scheduleService.js +55 -0
- package/dist/job/taskDelegation.js +48 -0
- package/dist/message/messageContinuation.js +1 -1
- package/dist/resources/projectResourceService.js +1 -1
- package/dist/resources/resourceGc.js +1 -1
- package/dist/role/role.js +12 -0
- package/dist/schedule/schedule.js +65 -15
- package/dist/scheduler/wakeReason.js +4 -0
- package/dist/storage/agentJobSchema.js +10 -0
- package/dist/storage/recordValidation.js +2 -0
- package/dist/storage/sqliteSchema.js +77 -6
- package/dist/storage/sqliteStore.js +53 -0
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +3 -0
- package/dist/storage/taskCatalog.js +10 -2
- package/dist/storage/upgrade/jobBusinessResultMigration.js +20 -0
- package/dist/storage/upgrade/scheduleDispatchMigration.js +15 -0
- package/dist/task/archivePreflight.js +15 -0
- package/dist/task/completionReadiness.js +7 -0
- package/dist/task/task.js +17 -1
- package/dist/verification/verificationGateService.js +4 -0
- package/dist/web/assets/client/app/catalog.js +16 -0
- package/dist/web/assets/client/i18n/en.js +29 -2
- package/dist/web/assets/client/i18n/zh-CN.js +29 -2
- package/dist/web/assets/client/layout/manageShell.js +9 -0
- package/dist/web/assets/client/layout/router.js +1 -0
- package/dist/web/assets/client/views/jobs.js +166 -37
- package/dist/web/assets/client/views/task/files.js +5 -4
- package/dist/web/assets/client/views/task/page.js +5 -1
- package/dist/web/taskPreviews.js +2 -2
- package/dist/web/webJobs.js +36 -11
- package/dist/web/webMaterials.js +21 -9
- package/dist/web/webServer.js +6 -5
- package/dist/web/webTaskSurface.js +1 -0
- package/docs/job-runs.md +392 -17
- package/docs/release-workflow.md +42 -0
- package/docs/release-workflow.zh-CN.md +31 -0
- package/docs/schedules.md +69 -15
- package/docs/storage-baseline.md +38 -1
- package/docs/storage-baseline.zh-CN.md +27 -1
- package/docs/testing/verification-levels.md +2 -2
- package/docs/testing/verification-levels.zh-CN.md +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { createTaskAggregate } from "../commands/taskCommands.js";
|
|
3
|
+
import { isDurableJobTerminal, validateDurableJob } from "../job/durableJob.js";
|
|
4
|
+
import { resultDigest, runReference } from "../job/jobBusinessResult.js";
|
|
5
|
+
import { validateTaskJobConfiguration, workItemDefinitionDigest } from "../job/taskDelegation.js";
|
|
6
|
+
import { assertCallerAuthorized } from "./jobControl.js";
|
|
7
|
+
import { enqueueWork } from "../coordination/workMailboxQueue.js";
|
|
8
|
+
import { isDeepStrictEqual } from "node:util";
|
|
9
|
+
/** Scope authorization is performed by the Job ingress; provenance adds none. */
|
|
10
|
+
export function authorizeTaskJob(store, job, caller, write) {
|
|
11
|
+
if (job.configuration.kind !== "task")
|
|
12
|
+
throw new Error("Not a Task delegation.");
|
|
13
|
+
validateTaskJobConfiguration(job.configuration);
|
|
14
|
+
if (caller.scope === "task") {
|
|
15
|
+
if (!write && caller.role === "leader")
|
|
16
|
+
return;
|
|
17
|
+
// Creating another Task is a cross-Task effect, not inherited authority.
|
|
18
|
+
if (caller.role !== "leader" || job.configuration.target.kind === "new"
|
|
19
|
+
|| job.configuration.target.taskId !== caller.taskId) {
|
|
20
|
+
throw new Error("Task delegation requires exact target authority; source Job scope grants no cross-Task permission.");
|
|
21
|
+
}
|
|
22
|
+
assertCallerAuthorized(store, caller, caller.taskId);
|
|
23
|
+
if (write) {
|
|
24
|
+
const session = store.getTaskRoleSessionSet(caller.taskId, "leader");
|
|
25
|
+
const effective = session?.sessions[session.activeAgentId]?.effective;
|
|
26
|
+
if (effective?.executionAuthority !== "delivery" || effective.profileAccess !== "write") {
|
|
27
|
+
throw new Error("Task delegation requires a current writable delivery Session.");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (!write)
|
|
32
|
+
return;
|
|
33
|
+
if (job.scope.kind === "task") {
|
|
34
|
+
const source = store.getTask(job.scope.taskId);
|
|
35
|
+
if (!source || source.status !== "active" || source.executionGate.state !== "enabled")
|
|
36
|
+
throw new Error("Source Task is not executable.");
|
|
37
|
+
}
|
|
38
|
+
const target = job.configuration.target;
|
|
39
|
+
if (target.kind === "new") {
|
|
40
|
+
for (const binding of target.projectBindings) {
|
|
41
|
+
const project = store.listProjects().find(project => project.id === binding.projectId);
|
|
42
|
+
if (!project || project.status !== "active")
|
|
43
|
+
throw new Error("Target Project is not active.");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
const task = store.getTask(target.taskId);
|
|
48
|
+
const work = store.getWorkItem(target.taskId, target.workItemId);
|
|
49
|
+
if (!task || task.status !== "active" || task.executionGate.state !== "enabled"
|
|
50
|
+
|| !work || work.status !== "open" || work.revision !== target.revision) {
|
|
51
|
+
throw new Error("An exact open WorkItem revision in an executable Task is required.");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Inside Run admission's transaction: Task/Leader + Run + association commit together. */
|
|
56
|
+
export function admitTaskJob(store, job, caller, trigger, requestId, now, retryOf) {
|
|
57
|
+
const config = job.configuration;
|
|
58
|
+
const id = job.scope.kind === "global" ? `job-${randomUUID()}` : store.nextDurableJobId(job.scope.taskId);
|
|
59
|
+
const sourceRun = { scope: job.scope, runId: id };
|
|
60
|
+
const target = config.target;
|
|
61
|
+
const taskId = target.kind === "new" ? createTaskAggregate(store, target.title, {
|
|
62
|
+
description: target.description, projectBindings: target.projectBindings,
|
|
63
|
+
...(target.type === undefined ? {} : { type: target.type })
|
|
64
|
+
}, now, [], { kind: "job-run", jobId: job.id, run: sourceRun }).task.id : target.taskId;
|
|
65
|
+
const delegation = { taskId, goal: config.goal,
|
|
66
|
+
...(target.kind === "work-item" ? { workItemId: target.workItemId, workItemRevision: target.revision,
|
|
67
|
+
workItemDefinitionDigest: workItemDefinitionDigest(store.getWorkItem(taskId, target.workItemId)) } : {}) };
|
|
68
|
+
const actorId = caller.scope === "user" ? "user" : `${caller.scope}:${caller.role}`;
|
|
69
|
+
const inputDigest = resultDigest(JSON.stringify([job, trigger, retryOf]));
|
|
70
|
+
const locator = `artifacts/jobs/${job.scope.kind === "global" ? "global" : job.scope.taskId}/${id}`;
|
|
71
|
+
const run = validateDurableJob({
|
|
72
|
+
schemaVersion: 1,
|
|
73
|
+
...(job.scope.kind === "global" ? { scope: "global" }
|
|
74
|
+
: { taskId: job.scope.taskId, owner: { kind: "task" } }),
|
|
75
|
+
id, definitionId: job.id, definitionRevision: job.revision, trigger,
|
|
76
|
+
delegation, artifactsLocator: locator,
|
|
77
|
+
idempotencyKey: createHash("sha256").update(JSON.stringify([actorId, requestId])).digest("hex"),
|
|
78
|
+
operation: { requestId, actorId, inputDigest, authorityRef: inputDigest,
|
|
79
|
+
targetId: taskId, capability: "job.run", implementation: { id: "yui:task-delegation", generation: "1" },
|
|
80
|
+
effect: "confirmed", receiptRefs: [`tasks/${taskId}`], partialResultRefs: [] },
|
|
81
|
+
status: "running", startedAt: now.toISOString(),
|
|
82
|
+
...(retryOf === undefined ? {} : { retryOf }),
|
|
83
|
+
createdAt: now.toISOString(), updatedAt: now.toISOString()
|
|
84
|
+
});
|
|
85
|
+
saveRun(store, run);
|
|
86
|
+
if (config.goal === "task-created")
|
|
87
|
+
return reconcileTaskDelegation(store, run, now);
|
|
88
|
+
return run;
|
|
89
|
+
}
|
|
90
|
+
function saveRun(store, run) {
|
|
91
|
+
if ("taskId" in run)
|
|
92
|
+
store.saveDurableJob(run.taskId, run);
|
|
93
|
+
else
|
|
94
|
+
store.saveGlobalJobRun(run);
|
|
95
|
+
}
|
|
96
|
+
/** Collection is transactional, repeatable, and never executes/activates/stops a Task. */
|
|
97
|
+
export function reconcileTaskDelegation(store, observed, now) {
|
|
98
|
+
return store.transaction(tx => {
|
|
99
|
+
const run = "taskId" in observed ? tx.getDurableJob(observed.taskId, observed.id) : tx.getGlobalJobRun(observed.id);
|
|
100
|
+
if (!run || !run.delegation || isDurableJobTerminal(run.status))
|
|
101
|
+
return run ?? observed;
|
|
102
|
+
const link = run.delegation;
|
|
103
|
+
const task = tx.getTask(link.taskId);
|
|
104
|
+
if (!task)
|
|
105
|
+
throw new Error("Delegated Task is missing; preserve the exact Run for inspection.");
|
|
106
|
+
if (link.goal !== "work-item" && (task.source?.jobId !== run.definitionId
|
|
107
|
+
|| !isDeepStrictEqual(task.source.run, runReference(run)))) {
|
|
108
|
+
throw new Error("Delegated Task creation source does not match the exact Run.");
|
|
109
|
+
}
|
|
110
|
+
let outcome;
|
|
111
|
+
let summary = "";
|
|
112
|
+
let evidence = [];
|
|
113
|
+
if (link.goal === "task-created") {
|
|
114
|
+
outcome = "succeeded";
|
|
115
|
+
summary = `Explicit creation goal met: ${task.id}; no delivery completion is claimed.`;
|
|
116
|
+
const event = tx.listEventsByType(task.id, ["task.created"])[0];
|
|
117
|
+
if (!event)
|
|
118
|
+
throw new Error("Delegated Task creation evidence is missing.");
|
|
119
|
+
evidence = [{ kind: "task-result", owner: { kind: "task", taskId: task.id },
|
|
120
|
+
sourceRun: runReference(run), version: resultDigest(JSON.stringify(event)), locator: `events/${event.id}` }];
|
|
121
|
+
}
|
|
122
|
+
else if (link.goal === "work-item") {
|
|
123
|
+
const work = tx.getWorkItem(task.id, link.workItemId);
|
|
124
|
+
if (!work || workItemDefinitionDigest(work) !== link.workItemDefinitionDigest) {
|
|
125
|
+
outcome = "unknown-needs-attention";
|
|
126
|
+
summary = "Delegated WorkItem requirements changed or are missing; inspect the exact scope.";
|
|
127
|
+
}
|
|
128
|
+
else if (work.status === "retired") {
|
|
129
|
+
outcome = "failed";
|
|
130
|
+
summary = `Delegated WorkItem retired: ${work.id}.`;
|
|
131
|
+
}
|
|
132
|
+
else if (work.status === "accepted") {
|
|
133
|
+
const candidate = work.candidates.find(candidate => candidate.id === work.acceptedCandidateId
|
|
134
|
+
&& candidate.workItemRevision >= link.workItemRevision);
|
|
135
|
+
if (!candidate)
|
|
136
|
+
throw new Error("Accepted delegation has no exact Candidate; preserve evidence.");
|
|
137
|
+
outcome = "succeeded";
|
|
138
|
+
summary = work.outcome ?? candidate.summary;
|
|
139
|
+
evidence = [{ kind: "work-item-result", owner: { kind: "task", taskId: task.id },
|
|
140
|
+
sourceRun: runReference(run), version: resultDigest(JSON.stringify(candidate)),
|
|
141
|
+
locator: `work-items/${work.id}/candidates/${candidate.id}` }];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
// The never-rewritten completion event identifies the exact lifecycle,
|
|
146
|
+
// even if the Task is reopened before this collector runs.
|
|
147
|
+
const event = tx.listEventsByType(task.id, ["task.completed"]).find(event => event.createdAt >= run.createdAt);
|
|
148
|
+
if (event) {
|
|
149
|
+
outcome = "succeeded";
|
|
150
|
+
summary = String(event.payload.summary);
|
|
151
|
+
evidence = [{ kind: "task-result", owner: { kind: "task", taskId: task.id },
|
|
152
|
+
sourceRun: runReference(run), version: resultDigest(JSON.stringify(event)), locator: `events/${event.id}` }];
|
|
153
|
+
}
|
|
154
|
+
else if (task.status === "cancelled" || (task.status === "archived" && task.completedAt === undefined)) {
|
|
155
|
+
outcome = "failed";
|
|
156
|
+
summary = `Delegated Task ended without the requested delivery: ${task.id}.`;
|
|
157
|
+
}
|
|
158
|
+
else if (task.completedAt !== undefined) {
|
|
159
|
+
outcome = "unknown-needs-attention";
|
|
160
|
+
summary = "Task reports completion without immutable completion-event evidence; inspect the target.";
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// Like process collection, already-proven delivery wins over a late cancel.
|
|
164
|
+
// Cancelling an unsettled wait never fabricates target resource quiescence.
|
|
165
|
+
if (!outcome && run.cancelRequestedAt) {
|
|
166
|
+
outcome = "cancelled";
|
|
167
|
+
summary = "This Run's delegation was cancelled; the target Task/Leader/WorkItem was not stopped. Inspect its own resources separately.";
|
|
168
|
+
}
|
|
169
|
+
if (!outcome)
|
|
170
|
+
return run;
|
|
171
|
+
const businessResult = { schemaVersion: 1, sourceRun: runReference(run), outcome,
|
|
172
|
+
summary, evidence };
|
|
173
|
+
const next = validateDurableJob({ ...run, status: outcome, businessResult, wakeupNotified: true,
|
|
174
|
+
terminalAt: now.toISOString(), updatedAt: now.toISOString() });
|
|
175
|
+
saveRun(tx, next);
|
|
176
|
+
if ("taskId" in run)
|
|
177
|
+
enqueueWork(tx, { kind: "role", taskId: run.taskId, roleName: "leader" }, "job-finished", now, [{ type: "task", id: run.taskId }]);
|
|
178
|
+
else if (run.operation.actorId !== "user")
|
|
179
|
+
enqueueWork(tx, { kind: "operator" }, "global-job-finished", now, [{ type: "global-job-run", id: run.id }], { source: "global-job", dedupeKey: `operator-global-job-run:${run.id}` });
|
|
180
|
+
return next;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/** Bounded projection only; source permissions never open the target's Context. */
|
|
184
|
+
export function taskDelegationView(store, run) {
|
|
185
|
+
if (!run.delegation)
|
|
186
|
+
return null;
|
|
187
|
+
const task = store.getTask(run.delegation.taskId);
|
|
188
|
+
return { ...run.delegation, taskStatus: task?.status ?? null,
|
|
189
|
+
executionGate: task?.executionGate.state ?? null,
|
|
190
|
+
openInputs: store.listInputRequests(run.delegation.taskId).filter(input => input.status === "open").length,
|
|
191
|
+
result: run.businessResult ?? null,
|
|
192
|
+
cancellation: run.cancelRequestedAt ? "requested; target resources not stopped" : null };
|
|
193
|
+
}
|
|
@@ -57,7 +57,9 @@ import { AgentHostProviderSessionBusyError, AgentHostProviderTurnFenceError, Fil
|
|
|
57
57
|
import { deliverGlobalInputs } from "./globalInputDelivery.js";
|
|
58
58
|
import { authorizeJobStart } from "./jobControl.js";
|
|
59
59
|
import { authorizeGlobalJobStart, createJobDefinitionControl } from "./jobDefinitionControl.js";
|
|
60
|
+
import { reconcileTaskDelegation } from "./jobTaskDelegationControl.js";
|
|
60
61
|
import { createScheduleControl, reconcileSchedules } from "./scheduleControl.js";
|
|
62
|
+
import { reconcileProjectSync } from "../job/projectSync.js";
|
|
61
63
|
import { createFileArtifactPort, createLinuxProcessPort, DurableJobSupervisor } from "./jobSupervisor.js";
|
|
62
64
|
import { createProviderRetryHooks } from "./providerRetryDelivery.js";
|
|
63
65
|
import { createRuntimeResourceActivityTracker } from "./resourceInventory.js";
|
|
@@ -488,6 +490,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
|
|
|
488
490
|
process: kernel.runner,
|
|
489
491
|
artifacts: createFileArtifactPort(home),
|
|
490
492
|
authorizeStart: (job) => "taskId" in job ? authorizeJobStart(store, job) : authorizeGlobalJobStart(store, job),
|
|
493
|
+
reconcileDelegation: (job, now) => { reconcileTaskDelegation(store, job, now); },
|
|
491
494
|
// rr6/f1: Bounded supervision wake. The supervisor signals the Controller
|
|
492
495
|
// after spawning a runner (queued→running adoption) and when a runner
|
|
493
496
|
// exits (terminal harvest), so a quick job converges without waiting for
|
|
@@ -575,7 +578,10 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
|
|
|
575
578
|
jobDefinitions: createJobDefinitionControl(store, home),
|
|
576
579
|
schedules: {
|
|
577
580
|
control: createScheduleControl(store, home),
|
|
578
|
-
reconcile: now =>
|
|
581
|
+
reconcile: now => {
|
|
582
|
+
reconcileProjectSync(store, now, options.onError);
|
|
583
|
+
reconcileSchedules(store, home, now, options.onError);
|
|
584
|
+
}
|
|
579
585
|
},
|
|
580
586
|
capabilityDispatcher: createCapabilityDispatcher(kernel.capabilities),
|
|
581
587
|
...(continuationReconciler === undefined ? {} : { continuationReconciler }),
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { requireKnownFields } from "../domain/validation.js";
|
|
2
|
-
import { nextScheduleDue, scheduleId, validateSchedule } from "../schedule/schedule.js";
|
|
3
|
-
import { admitJobRun, authorizeDefinition, authorizeScope,
|
|
3
|
+
import { nextScheduleDue, scheduleId, validateSchedule, validateScheduleTarget } from "../schedule/schedule.js";
|
|
4
|
+
import { admitJobRun, instantiateJobRun, authorizeDefinition, authorizeScope, authorizeRunRead } from "./jobDefinitionControl.js";
|
|
4
5
|
import { jobAuthorityBinding } from "./jobControl.js";
|
|
5
6
|
import { createGlobalRoleMessage } from "../message/message.js";
|
|
6
7
|
import { createTaskEvent } from "../event/taskEvent.js";
|
|
7
8
|
import { enqueueSupervisorEvent } from "../scheduler/operatorEvent.js";
|
|
8
9
|
import { redactLaunchText } from "../runtime/launchDiagnostics.js";
|
|
10
|
+
import { requireScheduleService, scheduleServiceBinding } from "../job/scheduleService.js";
|
|
11
|
+
import { updateProjectSync } from "../job/projectSync.js";
|
|
9
12
|
function binding(store, caller, scope) {
|
|
10
13
|
return jobAuthorityBinding(store, caller.scope, caller.role, scope.kind === "task" ? scope.taskId : "");
|
|
11
14
|
}
|
|
@@ -19,13 +22,35 @@ function authorizeSchedule(store, schedule, caller, write) {
|
|
|
19
22
|
function fence(schedule, now) {
|
|
20
23
|
return new Date(Math.max(now.getTime(), Date.parse(schedule?.processedThrough ?? "") || 0));
|
|
21
24
|
}
|
|
22
|
-
|
|
25
|
+
function authorization(store, plan, job, caller, now, requested, previous) {
|
|
26
|
+
if (requested !== undefined && requested !== "session" && requested !== "service")
|
|
27
|
+
throw new Error("Invalid Schedule authorization choice.");
|
|
28
|
+
if (plan.target.kind === "new-job" && plan.target.definitionRevision !== job.revision) {
|
|
29
|
+
throw new Error("Source Job revision changed; select its exact available revision.");
|
|
30
|
+
}
|
|
31
|
+
if (requested === "service") {
|
|
32
|
+
const grantId = randomUUID();
|
|
33
|
+
return { kind: "service", service: "schedule", grantId, revoked: false, jobRevision: job.revision,
|
|
34
|
+
grantedBy: structuredClone(caller), grantedAt: now.toISOString(),
|
|
35
|
+
binding: scheduleServiceBinding(store, plan, job, grantId) };
|
|
36
|
+
}
|
|
37
|
+
if (requested === undefined && previous?.kind === "service") {
|
|
38
|
+
if (previous.revoked || previous.binding !== scheduleServiceBinding(store, plan, job, previous.grantId)) {
|
|
39
|
+
throw new Error("Changed or revoked service authority requires explicit authorization=service.");
|
|
40
|
+
}
|
|
41
|
+
return previous;
|
|
42
|
+
}
|
|
43
|
+
if (caller.scope === "user")
|
|
44
|
+
throw new Error("Public Schedule creation/rebinding requires explicit authorization=service.");
|
|
45
|
+
return { kind: "session", caller: structuredClone(caller), binding: binding(store, caller, plan.scope), jobRevision: job.revision };
|
|
46
|
+
}
|
|
47
|
+
export function createScheduleControl(store, _home) {
|
|
23
48
|
return (value, now) => {
|
|
24
49
|
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
25
50
|
throw new Error("Invalid Schedule request.");
|
|
26
|
-
requireKnownFields(value, ["op", "scope", "caller", "id", "jobId", "rule", "expectedRevision"], "Schedule request");
|
|
51
|
+
requireKnownFields(value, ["op", "scope", "caller", "id", "jobId", "target", "authorization", "rule", "expectedRevision"], "Schedule request");
|
|
27
52
|
const input = value;
|
|
28
|
-
authorizeScope(store, input.scope, input.caller);
|
|
53
|
+
authorizeScope(store, input.scope, input.caller, true);
|
|
29
54
|
return store.transaction(tx => {
|
|
30
55
|
if (input.op === "list") {
|
|
31
56
|
return tx.listSchedules(input.scope).filter(s => {
|
|
@@ -45,9 +70,13 @@ export function createScheduleControl(store, home) {
|
|
|
45
70
|
const job = tx.getJobDefinition(input.scope, input.jobId);
|
|
46
71
|
if (job === null)
|
|
47
72
|
throw new Error("Schedule Job definition is missing.");
|
|
73
|
+
if (job.builtin)
|
|
74
|
+
throw new Error("Builtin Job has one installed Schedule; manage that Schedule instead.");
|
|
48
75
|
if (previous !== null) {
|
|
49
76
|
authorizeSchedule(tx, previous, input.caller, false);
|
|
50
|
-
if (previous.jobId !== input.jobId || JSON.stringify(previous.rule) !== JSON.stringify(input.rule)
|
|
77
|
+
if (previous.jobId !== input.jobId || JSON.stringify(previous.rule) !== JSON.stringify(input.rule)
|
|
78
|
+
|| JSON.stringify(previous.target) !== JSON.stringify(input.target ?? { kind: "existing-job" })
|
|
79
|
+
|| (input.authorization !== undefined && previous.authorization.kind !== input.authorization)) {
|
|
51
80
|
throw new Error("Schedule id already exists; use update with its revision.");
|
|
52
81
|
}
|
|
53
82
|
return previous;
|
|
@@ -56,10 +85,12 @@ export function createScheduleControl(store, home) {
|
|
|
56
85
|
const due = nextScheduleDue(input.rule, now);
|
|
57
86
|
if (due === null)
|
|
58
87
|
throw new Error("A new once Schedule must be in the future.");
|
|
88
|
+
const target = validateScheduleTarget(input.target ?? { kind: "existing-job" });
|
|
59
89
|
const schedule = validateSchedule({
|
|
60
90
|
schemaVersion: 1, id: input.id, scope: structuredClone(input.scope), jobId: input.jobId,
|
|
91
|
+
target,
|
|
61
92
|
rule: structuredClone(input.rule), state: "enabled", revision: 1, generation: 1, nextDue: due,
|
|
62
|
-
authorization: {
|
|
93
|
+
authorization: authorization(tx, { id: input.id, scope: input.scope, jobId: input.jobId, target }, job, input.caller, now, input.authorization),
|
|
63
94
|
createdAt: now.toISOString(), updatedAt: now.toISOString()
|
|
64
95
|
});
|
|
65
96
|
tx.saveSchedule(schedule);
|
|
@@ -69,20 +100,40 @@ export function createScheduleControl(store, home) {
|
|
|
69
100
|
throw new Error("Schedule not found.");
|
|
70
101
|
authorizeSchedule(tx, previous, input.caller, false);
|
|
71
102
|
if (input.op === "show") {
|
|
72
|
-
const runs =
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
103
|
+
const runs = input.scope.kind === "task" ? tx.listDurableJobs(input.scope.taskId) : tx.listGlobalJobRuns();
|
|
104
|
+
const ownRuns = runs.filter(run => {
|
|
105
|
+
if (run.trigger.source !== "schedule" || !run.trigger.key.startsWith(`${previous.id}:`))
|
|
106
|
+
return false;
|
|
107
|
+
try {
|
|
108
|
+
authorizeRunRead(tx, run, input.caller);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
76
115
|
const lastSuccess = ownRuns.filter(r => r.status === "succeeded")
|
|
77
116
|
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0] ?? null;
|
|
78
|
-
return { schedule: previous, lastSuccess };
|
|
117
|
+
return { schedule: previous, lastSuccess, runs: ownRuns };
|
|
79
118
|
}
|
|
80
119
|
if (previous.revision !== input.expectedRevision)
|
|
81
120
|
throw new Error("Schedule revision changed; read it before modifying.");
|
|
121
|
+
if (previous.builtin) {
|
|
122
|
+
if (input.jobId !== undefined || input.target !== undefined || input.authorization !== undefined) {
|
|
123
|
+
throw new Error("Builtin Schedule target and service registration cannot be overridden.");
|
|
124
|
+
}
|
|
125
|
+
return updateProjectSync(tx, previous, input.op, input.rule, now);
|
|
126
|
+
}
|
|
82
127
|
let next;
|
|
83
128
|
if (input.op === "disable") {
|
|
84
129
|
next = { ...previous, state: "disabled", reason: "Explicitly disabled; no future admission. Existing Runs are unchanged." };
|
|
85
130
|
}
|
|
131
|
+
else if (input.op === "revoke") {
|
|
132
|
+
if (previous.authorization.kind !== "service")
|
|
133
|
+
throw new Error("Only a service grant can be revoked; disable Session plans.");
|
|
134
|
+
next = { ...previous, state: "disabled", authorization: { ...previous.authorization, revoked: true },
|
|
135
|
+
reason: "Service authorization explicitly revoked; future admission and not-yet-started service Runs are denied." };
|
|
136
|
+
}
|
|
86
137
|
else if (input.op === "enable" || input.op === "update") {
|
|
87
138
|
if (input.op === "enable" && ["completed", "retired"].includes(previous.state)) {
|
|
88
139
|
throw new Error("Completed/retired Schedule requires an explicit future rule update.");
|
|
@@ -90,7 +141,10 @@ export function createScheduleControl(store, home) {
|
|
|
90
141
|
const jobId = input.op === "update" ? input.jobId ?? previous.jobId : previous.jobId;
|
|
91
142
|
scheduleId(jobId);
|
|
92
143
|
const rule = input.op === "update" ? input.rule : previous.rule;
|
|
144
|
+
const target = validateScheduleTarget(input.op === "update" ? input.target ?? previous.target : previous.target);
|
|
93
145
|
const job = authorizeSchedule(tx, { ...previous, jobId }, input.caller, true);
|
|
146
|
+
if (job.builtin)
|
|
147
|
+
throw new Error("Builtin Job has one installed Schedule.");
|
|
94
148
|
const after = fence(previous, now);
|
|
95
149
|
// Once overlap/disabled intent keeps its original due instant on enable.
|
|
96
150
|
const nextDue = input.op === "enable" && rule.kind === "once"
|
|
@@ -98,11 +152,11 @@ export function createScheduleControl(store, home) {
|
|
|
98
152
|
if (nextDue === null)
|
|
99
153
|
throw new Error("Rule must have a future occurrence.");
|
|
100
154
|
next = {
|
|
101
|
-
...previous, jobId, rule: structuredClone(rule), nextDue,
|
|
155
|
+
...previous, jobId, target, rule: structuredClone(rule), nextDue,
|
|
102
156
|
state: input.op === "enable" || previous.state === "enabled" ? "enabled" : "disabled",
|
|
103
157
|
generation: previous.generation + (input.op === "update" ? 1 : 0),
|
|
104
158
|
processedThrough: after.toISOString(),
|
|
105
|
-
authorization: {
|
|
159
|
+
authorization: authorization(tx, { ...previous, jobId, target }, job, input.caller, now, input.authorization, previous.authorization)
|
|
106
160
|
};
|
|
107
161
|
const { reason: _reason, ...withoutReason } = next;
|
|
108
162
|
next = withoutReason;
|
|
@@ -133,18 +187,27 @@ export function reconcileSchedules(store, home, now, onError = () => { }, limit
|
|
|
133
187
|
return;
|
|
134
188
|
}
|
|
135
189
|
}
|
|
136
|
-
const
|
|
190
|
+
const auth = schedule.authorization;
|
|
191
|
+
const service = auth.kind === "service" ? { scheduleId: schedule.id, grantId: auth.grantId } : undefined;
|
|
192
|
+
if (service)
|
|
193
|
+
requireScheduleService(tx, service);
|
|
194
|
+
const caller = auth.kind === "session" ? auth.caller : { scope: "user" };
|
|
195
|
+
const job = authorizeSchedule(tx, schedule, caller, true);
|
|
137
196
|
if (job.revision !== schedule.authorization.jobRevision
|
|
138
|
-
|| binding(tx,
|
|
197
|
+
|| (auth.kind === "session" && binding(tx, caller, schedule.scope) !== auth.binding)) {
|
|
139
198
|
throw new Error("Schedule authority or Job revision changed; inspect and explicitly enable.");
|
|
140
199
|
}
|
|
141
|
-
const
|
|
200
|
+
const trigger = { source: "schedule", key: `${schedule.id}:${schedule.generation}:${dueAt}` };
|
|
201
|
+
const result = schedule.target.kind === "existing-job"
|
|
202
|
+
? admitJobRun(tx, home, schedule.scope, schedule.jobId, caller, trigger, now, undefined, service)
|
|
203
|
+
: instantiateJobRun(tx, home, schedule.scope, schedule.jobId, schedule.target.definitionRevision, caller, trigger, now, service);
|
|
142
204
|
const overlap = result.reason === "overlap";
|
|
143
205
|
const nextDue = overlap && schedule.rule.kind === "once" ? dueAt : nextScheduleDue(schedule.rule, after);
|
|
144
206
|
tx.saveSchedule({
|
|
145
207
|
...schedule, nextDue, state: nextDue === null ? "completed" : "enabled",
|
|
146
208
|
processedThrough: after.toISOString(), revision: schedule.revision + 1,
|
|
147
|
-
lastAttempt: { at: now.toISOString(), dueAt,
|
|
209
|
+
lastAttempt: { at: now.toISOString(), dueAt, generation: schedule.generation,
|
|
210
|
+
outcome: overlap ? "overlap" : "admitted", jobId: result.run.definitionId, runId: result.run.id },
|
|
148
211
|
updatedAt: now.toISOString()
|
|
149
212
|
});
|
|
150
213
|
});
|
|
@@ -157,14 +220,14 @@ export function reconcileSchedules(store, home, now, onError = () => { }, limit
|
|
|
157
220
|
if (current?.state !== "enabled" || current.revision !== candidate.revision)
|
|
158
221
|
return;
|
|
159
222
|
tx.saveSchedule({ ...current, state: "needs-attention", reason, revision: current.revision + 1,
|
|
160
|
-
lastAttempt: { at: now.toISOString(), dueAt: current.nextDue, outcome: "blocked", reason },
|
|
223
|
+
lastAttempt: { at: now.toISOString(), dueAt: current.nextDue, generation: current.generation, outcome: "blocked", reason },
|
|
161
224
|
updatedAt: now.toISOString() });
|
|
162
225
|
if (current.scope.kind === "task") {
|
|
163
226
|
const event = createTaskEvent(tx.nextEventId(current.scope.taskId), current.scope.taskId, "schedule.needs-attention", { scheduleId: current.id, reason }, now);
|
|
164
227
|
tx.saveEvent(current.scope.taskId, event);
|
|
165
228
|
enqueueSupervisorEvent(tx, event, "leader", "schedule-needs-attention", now);
|
|
166
229
|
}
|
|
167
|
-
else {
|
|
230
|
+
else if (tx.getGlobalRole("operator") !== null) {
|
|
168
231
|
tx.saveGlobalRoleMessage(createGlobalRoleMessage(tx.nextGlobalRoleMessageId(), "operator", `Yui-Summary: Schedule ${current.id} needs attention; no Run was admitted.\n`
|
|
169
232
|
+ `Reason: ${reason}\nRead: yui schedule show --global --schedule ${current.id}\n`
|
|
170
233
|
+ "Inspect the original intent before explicitly enabling; no new authority is implied.", "system", { type: "system" }, now, { inputControl: { action: "queue", requestId: `schedule-attention:${current.id}:${current.revision}` } }));
|
|
@@ -460,6 +460,8 @@ export class GitIntegrationService {
|
|
|
460
460
|
*/
|
|
461
461
|
async #resumeCheckJob(attempt, workspace, path, repositoryPath, gate) {
|
|
462
462
|
const job = await this.jobPort.getJob(attempt.taskId, attempt.jobId);
|
|
463
|
+
if (job.delegation)
|
|
464
|
+
throw new Error("Task delegation is not an Integration check Job.");
|
|
463
465
|
assertCheckJobIdentity(attempt, job, path);
|
|
464
466
|
await assertIntegrationCandidate(path, job.head, workspace.branch);
|
|
465
467
|
await assertTargetReadyForChecks(repositoryPath, attempt.targetRef, attempt.beforeCommit, this.#targetCheckout(attempt));
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
|
+
import { requireIdentity, requireKnownFields, requirePositiveInteger, requireText, requireTimestamp } from "../domain/validation.js";
|
|
4
|
+
import { jobScopeKey } from "./jobDefinition.js";
|
|
5
|
+
import { validateBusinessResult } from "./jobBusinessResult.js";
|
|
6
|
+
import { validateRunArtifacts, artifactsAppendOnly } from "../artifacts/jobArtifact.js";
|
|
7
|
+
export function validateAgentJobConfiguration(config) {
|
|
8
|
+
requireKnownFields(config, ["kind", "target", "input", "concurrency"], "Agent Job configuration");
|
|
9
|
+
if (config.kind !== "agent")
|
|
10
|
+
throw new Error("Invalid Agent Job executor.");
|
|
11
|
+
requireText(config.input, "Agent Job input");
|
|
12
|
+
if (Buffer.byteLength(config.input) > 65536)
|
|
13
|
+
throw new Error("Agent Job input exceeds 64 KiB.");
|
|
14
|
+
if (config.concurrency !== undefined && !["serial", "isolated"].includes(config.concurrency)) {
|
|
15
|
+
throw new Error("Invalid Agent Job concurrency.");
|
|
16
|
+
}
|
|
17
|
+
const target = config.target;
|
|
18
|
+
if (target.kind === "operator")
|
|
19
|
+
requireKnownFields(target, ["kind"], "Operator target");
|
|
20
|
+
else if (target.kind === "task") {
|
|
21
|
+
requireKnownFields(target, ["kind", "taskId", "roleName", "ownerRunId"], "Task Agent target");
|
|
22
|
+
jobScopeKey({ kind: "task", taskId: target.taskId });
|
|
23
|
+
requireIdentity(target.roleName, "Task Role");
|
|
24
|
+
if (target.roleName !== "leader" && !target.ownerRunId)
|
|
25
|
+
throw new Error("Task Agent target requires its exact Assignment ownerRunId.");
|
|
26
|
+
if (target.ownerRunId !== undefined)
|
|
27
|
+
requireIdentity(target.ownerRunId, "Assignment Run");
|
|
28
|
+
}
|
|
29
|
+
else if (target.kind === "job") {
|
|
30
|
+
requireKnownFields(target, ["kind", "agentId"], "Job Agent target");
|
|
31
|
+
requireIdentity(target.agentId, "Job Agent");
|
|
32
|
+
}
|
|
33
|
+
else
|
|
34
|
+
throw new Error("Unknown Agent Job target.");
|
|
35
|
+
if (config.concurrency === "isolated" && target.kind !== "job") {
|
|
36
|
+
throw new Error("Isolated concurrency requires a Job-owned Agent; shared roles are serial.");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function jobAgentRoleName(scope, jobId, runId) {
|
|
40
|
+
return `job-${createHash("sha256").update(JSON.stringify([scope, jobId, runId ?? null])).digest("hex").slice(0, 32)}`;
|
|
41
|
+
}
|
|
42
|
+
export function validateAgentJobRun(run) {
|
|
43
|
+
requireKnownFields(run, ["schemaVersion", "executor", "id", "scope", "definitionId", "definitionRevision",
|
|
44
|
+
"configuration", "trigger", "retryOf", "status", "attempts", "businessResult", "cancelRequestedAt",
|
|
45
|
+
"createdAt", "updatedAt", "terminalAt", "artifacts"], "Agent JobRun");
|
|
46
|
+
if (run.schemaVersion !== 1 || run.executor !== "agent")
|
|
47
|
+
throw new Error("Invalid Agent JobRun.");
|
|
48
|
+
requireIdentity(run.id, "Agent JobRun id");
|
|
49
|
+
requireIdentity(run.definitionId, "Agent Job id");
|
|
50
|
+
requirePositiveInteger(run.definitionRevision, "Agent Job revision");
|
|
51
|
+
requireKnownFields(run.scope, run.scope.kind === "task" ? ["kind", "taskId"] : ["kind"], "Agent Job scope");
|
|
52
|
+
if (!["task", "global"].includes(run.scope.kind))
|
|
53
|
+
throw new Error("Invalid Agent Job scope.");
|
|
54
|
+
jobScopeKey(run.scope);
|
|
55
|
+
validateAgentJobConfiguration(run.configuration);
|
|
56
|
+
requireKnownFields(run.trigger, ["source", "key"], "Agent Job trigger");
|
|
57
|
+
if (!["manual", "retry", "schedule"].includes(run.trigger.source))
|
|
58
|
+
throw new Error("Invalid Agent Job trigger.");
|
|
59
|
+
requireText(run.trigger.key, "Agent Job trigger key");
|
|
60
|
+
if (!["queued", "running", "succeeded", "failed", "cancelled", "timed-out", "unknown-needs-attention"].includes(run.status)) {
|
|
61
|
+
throw new Error("Invalid Agent JobRun status.");
|
|
62
|
+
}
|
|
63
|
+
requireTimestamp(run.createdAt, "Agent JobRun createdAt");
|
|
64
|
+
requireTimestamp(run.updatedAt, "Agent JobRun updatedAt");
|
|
65
|
+
for (const value of [run.cancelRequestedAt, run.terminalAt])
|
|
66
|
+
if (value !== undefined)
|
|
67
|
+
requireTimestamp(value, "Agent JobRun timestamp");
|
|
68
|
+
if (!Array.isArray(run.attempts) || !run.attempts.length || run.attempts.length > 128)
|
|
69
|
+
throw new Error("Invalid Agent Job attempts.");
|
|
70
|
+
for (const attempt of run.attempts) {
|
|
71
|
+
requireKnownFields(attempt, ["messageId", "recipient", "createdAt", "execution", "terminal"], "Agent Job attempt");
|
|
72
|
+
requireIdentity(attempt.messageId, "Agent Job message");
|
|
73
|
+
requireTimestamp(attempt.createdAt, "Agent Job attempt timestamp");
|
|
74
|
+
const recipient = attempt.recipient;
|
|
75
|
+
requireKnownFields(recipient, recipient.scope === "task"
|
|
76
|
+
? ["scope", "taskId", "roleName", "ownerRunId"] : ["scope", "roleName"], "Agent Job recipient");
|
|
77
|
+
if (!["task", "global"].includes(recipient.scope))
|
|
78
|
+
throw new Error("Invalid Agent Job recipient.");
|
|
79
|
+
requireIdentity(recipient.roleName, "Agent Job recipient Role");
|
|
80
|
+
if (recipient.scope === "task")
|
|
81
|
+
jobScopeKey({ kind: "task", taskId: recipient.taskId });
|
|
82
|
+
if (attempt.execution) {
|
|
83
|
+
requireKnownFields(attempt.execution, ["nativeSessionId", "attemptId", "nativeTurnId"], "Agent Job execution");
|
|
84
|
+
for (const value of Object.values(attempt.execution)) {
|
|
85
|
+
if (typeof value !== "string")
|
|
86
|
+
throw new Error("Invalid Agent execution identity.");
|
|
87
|
+
requireText(value, "Agent Job execution identity");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (attempt.terminal) {
|
|
91
|
+
requireKnownFields(attempt.terminal, ["status", "observedAt"], "Agent Turn terminal");
|
|
92
|
+
if (!attempt.execution || !["completed", "failed", "cancelled"].includes(attempt.terminal.status))
|
|
93
|
+
throw new Error("Invalid Agent terminal evidence.");
|
|
94
|
+
requireTimestamp(attempt.terminal.observedAt, "Agent terminal observation");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (run.businessResult) {
|
|
98
|
+
validateBusinessResult(run.businessResult, { scope: run.scope, runId: run.id });
|
|
99
|
+
if (run.status !== run.businessResult.outcome || !run.terminalAt)
|
|
100
|
+
throw new Error("Agent result and terminal disagree.");
|
|
101
|
+
}
|
|
102
|
+
else if (["succeeded", "failed", "cancelled", "timed-out"].includes(run.status)) {
|
|
103
|
+
throw new Error("Agent Job terminal requires an explicit business result.");
|
|
104
|
+
}
|
|
105
|
+
validateRunArtifacts(run);
|
|
106
|
+
return run;
|
|
107
|
+
}
|
|
108
|
+
export function assertAgentJobRunTransition(previous, next) {
|
|
109
|
+
const mutable = ["status", "attempts", "businessResult", "cancelRequestedAt", "updatedAt", "terminalAt", "artifacts"];
|
|
110
|
+
for (const key of new Set([...Object.keys(previous), ...Object.keys(next)])) {
|
|
111
|
+
if (!mutable.includes(key) && !isDeepStrictEqual(previous[key], next[key]))
|
|
112
|
+
throw new Error("Agent Job snapshot is immutable.");
|
|
113
|
+
}
|
|
114
|
+
if (previous.businessResult && (!isDeepStrictEqual(previous.businessResult, next.businessResult)
|
|
115
|
+
|| previous.status !== next.status || previous.terminalAt !== next.terminalAt
|
|
116
|
+
|| previous.attempts.length !== next.attempts.length))
|
|
117
|
+
throw new Error("Agent Job result is immutable.");
|
|
118
|
+
if (!artifactsAppendOnly(previous, next))
|
|
119
|
+
throw new Error("Agent Job artifacts are append-only.");
|
|
120
|
+
if (previous.cancelRequestedAt !== undefined && previous.cancelRequestedAt !== next.cancelRequestedAt)
|
|
121
|
+
throw new Error("Cancellation is immutable.");
|
|
122
|
+
if (Date.parse(next.updatedAt) < Date.parse(previous.updatedAt) || next.attempts.length < previous.attempts.length)
|
|
123
|
+
throw new Error("Agent Job history cannot move backwards.");
|
|
124
|
+
previous.attempts.forEach((attempt, index) => {
|
|
125
|
+
const updated = next.attempts[index];
|
|
126
|
+
if (!isDeepStrictEqual({ ...attempt, execution: undefined, terminal: undefined }, { ...updated, execution: undefined, terminal: undefined })
|
|
127
|
+
|| attempt.execution && !isDeepStrictEqual(attempt.execution, updated.execution)
|
|
128
|
+
|| attempt.terminal && !isDeepStrictEqual(attempt.terminal, updated.terminal))
|
|
129
|
+
throw new Error("Agent attempt evidence is immutable.");
|
|
130
|
+
});
|
|
131
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { currentProviderConversation } from "../runtime/providerRuntimeIdentity.js";
|
|
3
|
+
export function jobAttemptOwnsInput(store, attempt, binding) {
|
|
4
|
+
const { recipient, messageId } = attempt;
|
|
5
|
+
const inputs = [binding.run?.input];
|
|
6
|
+
if (binding.run && binding.retry?.currentAttemptId === binding.run.attemptId)
|
|
7
|
+
inputs.push(binding.retry.input);
|
|
8
|
+
return inputs.some(input => {
|
|
9
|
+
if (recipient.scope === "global")
|
|
10
|
+
return input?.kind === "message"
|
|
11
|
+
&& input.roleName === recipient.roleName && input.messageId === messageId;
|
|
12
|
+
if (input?.kind === "wake" && input.taskId === recipient.taskId)
|
|
13
|
+
return store.getTaskWake(recipient.taskId, input.wakeId)
|
|
14
|
+
?.refs?.some(ref => ref.type === "message" && ref.id === messageId) === true;
|
|
15
|
+
return input?.kind === "run" && input.taskId === recipient.taskId
|
|
16
|
+
&& store.listMessages(recipient.taskId).find(message => message.id === messageId)?.continuation?.runId === input.runId;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/** Collect existing Provider evidence at its owning transaction. No submission,
|
|
20
|
+
* retry, inferred business result, or transcript parsing occurs here. */
|
|
21
|
+
export function recordJobAgentObservation(store, recipient, binding, now) {
|
|
22
|
+
if (!binding?.run)
|
|
23
|
+
return;
|
|
24
|
+
for (const run of store.listAgentJobRunsForRole(recipient)) {
|
|
25
|
+
const last = run.attempts.at(-1);
|
|
26
|
+
if (!jobAttemptOwnsInput(store, last, binding))
|
|
27
|
+
continue;
|
|
28
|
+
const turn = binding.run;
|
|
29
|
+
const attempts = [...run.attempts];
|
|
30
|
+
let attempt = last;
|
|
31
|
+
if (turn.nativeTurnId && ["accepted", "completed", "failed", "cancelled"].includes(turn.status)) {
|
|
32
|
+
const execution = { nativeSessionId: currentProviderConversation(binding).conversationId,
|
|
33
|
+
attemptId: turn.attemptId, nativeTurnId: turn.nativeTurnId };
|
|
34
|
+
if (last.execution && !isDeepStrictEqual(last.execution, execution)) {
|
|
35
|
+
const retry = binding.retry;
|
|
36
|
+
// Only the existing bounded retry owner may prove a successor of the
|
|
37
|
+
// same original input. Session changes/other native Turns never do.
|
|
38
|
+
if (run.businessResult || retry?.currentAttemptId !== execution.attemptId
|
|
39
|
+
|| retry.failureAttemptId !== last.execution.attemptId || retry.failedNativeTurnId !== last.execution.nativeTurnId
|
|
40
|
+
|| retry.nativeSessionId !== last.execution.nativeSessionId)
|
|
41
|
+
continue;
|
|
42
|
+
attempts[attempts.length - 1] = { ...last, terminal: last.terminal ?? { status: "failed", observedAt: retry.updatedAt } };
|
|
43
|
+
attempt = { messageId: last.messageId, recipient: last.recipient, createdAt: turn.submittedAt, execution };
|
|
44
|
+
attempts.push(attempt);
|
|
45
|
+
}
|
|
46
|
+
else
|
|
47
|
+
attempt = { ...last, execution };
|
|
48
|
+
if (["completed", "failed", "cancelled"].includes(turn.status))
|
|
49
|
+
attempt = { ...attempt,
|
|
50
|
+
terminal: attempt.terminal ?? { status: turn.status, observedAt: now.toISOString() } };
|
|
51
|
+
attempts[attempts.length - 1] = attempt;
|
|
52
|
+
}
|
|
53
|
+
const status = run.businessResult ? run.status : turn.status === "delivery-unknown" ? "unknown-needs-attention"
|
|
54
|
+
: attempt.execution ? "running" : run.status;
|
|
55
|
+
if (status === run.status && isDeepStrictEqual(attempts, run.attempts))
|
|
56
|
+
continue;
|
|
57
|
+
const next = { ...run, status, attempts,
|
|
58
|
+
updatedAt: new Date(Math.max(now.getTime(), Date.parse(run.updatedAt))).toISOString() };
|
|
59
|
+
store.saveAgentJobRun(next);
|
|
60
|
+
}
|
|
61
|
+
}
|