@dsh-enhanced/assistant-work-items 0.0.0-stage → 0.1.67
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/LICENSE +21 -0
- package/README.md +685 -2
- package/cordis.patch.yml +11 -0
- package/lib/acceptance-binding.d.ts +86 -0
- package/lib/acceptance-binding.d.ts.map +1 -0
- package/lib/acceptance-binding.js +250 -0
- package/lib/acceptance-binding.js.map +1 -0
- package/lib/admission.d.ts +95 -0
- package/lib/admission.d.ts.map +1 -0
- package/lib/admission.js +642 -0
- package/lib/admission.js.map +1 -0
- package/lib/authority.d.ts +99 -0
- package/lib/authority.d.ts.map +1 -0
- package/lib/authority.js +233 -0
- package/lib/authority.js.map +1 -0
- package/lib/config.d.ts +365 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +672 -0
- package/lib/config.js.map +1 -0
- package/lib/continuation-contract.d.ts +126 -0
- package/lib/continuation-contract.d.ts.map +1 -0
- package/lib/continuation-contract.js +109 -0
- package/lib/continuation-contract.js.map +1 -0
- package/lib/continuation-record.d.ts +32 -0
- package/lib/continuation-record.d.ts.map +1 -0
- package/lib/continuation-record.js +57 -0
- package/lib/continuation-record.js.map +1 -0
- package/lib/continuation.d.ts +89 -0
- package/lib/continuation.d.ts.map +1 -0
- package/lib/continuation.js +607 -0
- package/lib/continuation.js.map +1 -0
- package/lib/diagnosis-contract.d.ts +95 -0
- package/lib/diagnosis-contract.d.ts.map +1 -0
- package/lib/diagnosis-contract.js +167 -0
- package/lib/diagnosis-contract.js.map +1 -0
- package/lib/diagnosis.d.ts +70 -0
- package/lib/diagnosis.d.ts.map +1 -0
- package/lib/diagnosis.js +466 -0
- package/lib/diagnosis.js.map +1 -0
- package/lib/dispatch.d.ts +100 -0
- package/lib/dispatch.d.ts.map +1 -0
- package/lib/dispatch.js +317 -0
- package/lib/dispatch.js.map +1 -0
- package/lib/engineering-context.d.ts +86 -0
- package/lib/engineering-context.d.ts.map +1 -0
- package/lib/engineering-context.js +263 -0
- package/lib/engineering-context.js.map +1 -0
- package/lib/evaluation.d.ts +125 -0
- package/lib/evaluation.d.ts.map +1 -0
- package/lib/evaluation.js +295 -0
- package/lib/evaluation.js.map +1 -0
- package/lib/event-follow.d.ts +93 -0
- package/lib/event-follow.d.ts.map +1 -0
- package/lib/event-follow.js +150 -0
- package/lib/event-follow.js.map +1 -0
- package/lib/feedback-targets.d.ts +91 -0
- package/lib/feedback-targets.d.ts.map +1 -0
- package/lib/feedback-targets.js +362 -0
- package/lib/feedback-targets.js.map +1 -0
- package/lib/index.d.ts +27 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +25 -0
- package/lib/index.js.map +1 -0
- package/lib/intake-admission.d.ts +49 -0
- package/lib/intake-admission.d.ts.map +1 -0
- package/lib/intake-admission.js +100 -0
- package/lib/intake-admission.js.map +1 -0
- package/lib/intake-evaluation-runtime.d.ts +74 -0
- package/lib/intake-evaluation-runtime.d.ts.map +1 -0
- package/lib/intake-evaluation-runtime.js +343 -0
- package/lib/intake-evaluation-runtime.js.map +1 -0
- package/lib/intake-evaluation.d.ts +180 -0
- package/lib/intake-evaluation.d.ts.map +1 -0
- package/lib/intake-evaluation.js +440 -0
- package/lib/intake-evaluation.js.map +1 -0
- package/lib/intake.d.ts +118 -0
- package/lib/intake.d.ts.map +1 -0
- package/lib/intake.js +137 -0
- package/lib/intake.js.map +1 -0
- package/lib/ledger.d.ts +86 -0
- package/lib/ledger.d.ts.map +1 -0
- package/lib/ledger.js +145 -0
- package/lib/ledger.js.map +1 -0
- package/lib/mandate.d.ts +45 -0
- package/lib/mandate.d.ts.map +1 -0
- package/lib/mandate.js +168 -0
- package/lib/mandate.js.map +1 -0
- package/lib/outcome.d.ts +137 -0
- package/lib/outcome.d.ts.map +1 -0
- package/lib/outcome.js +303 -0
- package/lib/outcome.js.map +1 -0
- package/lib/owner-access.d.ts +35 -0
- package/lib/owner-access.d.ts.map +1 -0
- package/lib/owner-access.js +152 -0
- package/lib/owner-access.js.map +1 -0
- package/lib/owner-continuation.d.ts +27 -0
- package/lib/owner-continuation.d.ts.map +1 -0
- package/lib/owner-continuation.js +131 -0
- package/lib/owner-continuation.js.map +1 -0
- package/lib/owner-recall.d.ts +85 -0
- package/lib/owner-recall.d.ts.map +1 -0
- package/lib/owner-recall.js +134 -0
- package/lib/owner-recall.js.map +1 -0
- package/lib/owner-replies.d.ts +44 -0
- package/lib/owner-replies.d.ts.map +1 -0
- package/lib/owner-replies.js +268 -0
- package/lib/owner-replies.js.map +1 -0
- package/lib/peers.d.ts +624 -0
- package/lib/peers.d.ts.map +1 -0
- package/lib/peers.js +10 -0
- package/lib/peers.js.map +1 -0
- package/lib/policy-notice.d.ts +31 -0
- package/lib/policy-notice.d.ts.map +1 -0
- package/lib/policy-notice.js +137 -0
- package/lib/policy-notice.js.map +1 -0
- package/lib/reply-contract.d.ts +67 -0
- package/lib/reply-contract.d.ts.map +1 -0
- package/lib/reply-contract.js +58 -0
- package/lib/reply-contract.js.map +1 -0
- package/lib/service.d.ts +632 -0
- package/lib/service.d.ts.map +1 -0
- package/lib/service.js +2447 -0
- package/lib/service.js.map +1 -0
- package/lib/settlement.d.ts +74 -0
- package/lib/settlement.d.ts.map +1 -0
- package/lib/settlement.js +233 -0
- package/lib/settlement.js.map +1 -0
- package/lib/signals.d.ts +176 -0
- package/lib/signals.d.ts.map +1 -0
- package/lib/signals.js +497 -0
- package/lib/signals.js.map +1 -0
- package/lib/store.d.ts +791 -0
- package/lib/store.d.ts.map +1 -0
- package/lib/store.js +3266 -0
- package/lib/store.js.map +1 -0
- package/lib/tools.d.ts +7 -0
- package/lib/tools.d.ts.map +1 -0
- package/lib/tools.js +443 -0
- package/lib/tools.js.map +1 -0
- package/lib/types.d.ts +831 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +29 -0
- package/lib/types.js.map +1 -0
- package/lib/version.d.ts +2 -0
- package/lib/version.d.ts.map +1 -0
- package/lib/version.js +2 -0
- package/lib/version.js.map +1 -0
- package/lib/workspace.d.ts +43 -0
- package/lib/workspace.d.ts.map +1 -0
- package/lib/workspace.js +137 -0
- package/lib/workspace.js.map +1 -0
- package/package.json +101 -3
package/lib/service.js
ADDED
|
@@ -0,0 +1,2447 @@
|
|
|
1
|
+
import { WorkItemEngineeringTargets } from './engineering-context.js';
|
|
2
|
+
import { Service } from '@deepseek-ai/cordis';
|
|
3
|
+
import { classifyIntake, intakeSourcePolicy } from './intake.js';
|
|
4
|
+
import { authenticatesIntakeText, WorkItemIntakeEvaluationRuntime } from './intake-evaluation-runtime.js';
|
|
5
|
+
import { WorkItemIntakeAdmissionBridge } from './intake-admission.js';
|
|
6
|
+
import { WorkItemAdmission } from './admission.js';
|
|
7
|
+
import { storedBasisHolds, WorkItemAuthority } from './authority.js';
|
|
8
|
+
import { ConfigSchema, isActive, normalizeConfig, text } from './config.js';
|
|
9
|
+
import { WorkItemDispatch } from './dispatch.js';
|
|
10
|
+
import { eventFollowReader, eventFollowRegistration } from './event-follow.js';
|
|
11
|
+
import { WorkItemEvaluationExporter } from './evaluation.js';
|
|
12
|
+
import { normalizeWorkItemReportSummary, WorkItemLedger } from './ledger.js';
|
|
13
|
+
import { settleOutcomes as runSettlement } from './settlement.js';
|
|
14
|
+
import { WorkItemOwnerRecall } from './owner-recall.js';
|
|
15
|
+
import { MALFORMED_ACCEPTANCE_BINDING, WorkItemStore } from './store.js';
|
|
16
|
+
import { normalizeFollowUp, normalizePolicyChange, noticePolicyChange } from './policy-notice.js';
|
|
17
|
+
import { resolveAcceptanceBinding, settleAcceptance, workerRunTaskRef } from './acceptance-binding.js';
|
|
18
|
+
import { WorkItemSignalIntake, } from './signals.js';
|
|
19
|
+
import { registerWorkItemTools, workItemReportKey } from './tools.js';
|
|
20
|
+
import { TERMINAL_STATES, WorkItemError } from './types.js';
|
|
21
|
+
import { WorkItemWorkspace } from './workspace.js';
|
|
22
|
+
import { WorkItemDiagnosis, persistedDiagnosisIntervention } from './diagnosis.js';
|
|
23
|
+
import { WorkItemContinuation } from './continuation.js';
|
|
24
|
+
import { WorkItemOwnerAccess } from './owner-access.js';
|
|
25
|
+
import { WorkItemOwnerContinuation } from './owner-continuation.js';
|
|
26
|
+
import { WorkItemOwnerReplies } from './owner-replies.js';
|
|
27
|
+
import { WorkItemOwnerFeedbackTargets } from './feedback-targets.js';
|
|
28
|
+
function fail(code, message, detail) {
|
|
29
|
+
throw new WorkItemError(code, message, detail);
|
|
30
|
+
}
|
|
31
|
+
/** Snapshot this shallow tool grammar without evaluating untrusted accessors. */
|
|
32
|
+
function workItemToolArguments(candidate, allowed) {
|
|
33
|
+
if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)
|
|
34
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(candidate)))
|
|
35
|
+
fail('invalid-input', 'work-item tool arguments must be a plain object');
|
|
36
|
+
const names = Reflect.ownKeys(candidate);
|
|
37
|
+
if (names.length > allowed.length || names.some(key => typeof key !== 'string' || !allowed.includes(key)))
|
|
38
|
+
fail('invalid-input', 'work-item tool arguments contain an unknown field');
|
|
39
|
+
const args = Object.create(null);
|
|
40
|
+
for (const key of names) {
|
|
41
|
+
const field = Object.getOwnPropertyDescriptor(candidate, key);
|
|
42
|
+
if (field === undefined || !field.enumerable || !('value' in field) || field.value === undefined)
|
|
43
|
+
fail('invalid-input', 'work-item tool arguments must contain only enumerable data fields');
|
|
44
|
+
if (key !== 'artifacts') {
|
|
45
|
+
args[key] = field.value;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const paths = field.value;
|
|
49
|
+
if (!Array.isArray(paths) || Object.getPrototypeOf(paths) !== Array.prototype)
|
|
50
|
+
fail('invalid-input', 'artifacts must be a plain array');
|
|
51
|
+
const length = Object.getOwnPropertyDescriptor(paths, 'length')?.value;
|
|
52
|
+
if (!Number.isSafeInteger(length) || length < 0 || length > 20)
|
|
53
|
+
fail('invalid-input', 'at most 20 artifact paths are accepted');
|
|
54
|
+
const indices = Reflect.ownKeys(paths);
|
|
55
|
+
if (indices.length !== length + 1 || indices.some(index => typeof index !== 'string'
|
|
56
|
+
|| (index !== 'length' && !/^(?:0|[1-9][0-9]*)$/u.test(index))))
|
|
57
|
+
fail('invalid-input', 'artifacts must not contain symbols, holes or extra fields');
|
|
58
|
+
const copied = [];
|
|
59
|
+
for (let index = 0; index < length; index += 1) {
|
|
60
|
+
const entry = Object.getOwnPropertyDescriptor(paths, String(index));
|
|
61
|
+
if (entry === undefined || !entry.enumerable || !('value' in entry) || typeof entry.value !== 'string')
|
|
62
|
+
fail('invalid-input', 'artifacts must contain only enumerable string data entries');
|
|
63
|
+
copied.push(entry.value);
|
|
64
|
+
}
|
|
65
|
+
args[key] = copied;
|
|
66
|
+
}
|
|
67
|
+
return args;
|
|
68
|
+
}
|
|
69
|
+
// Neither the runtime nor the plan-mint closure is published on a Cordis service receiver.
|
|
70
|
+
/** Rows `bindWorkerRun` may scan per wake, parked ones included. */
|
|
71
|
+
const WORKER_RUN_BIND_SCAN_LIMIT = 100;
|
|
72
|
+
/** Upper bound of in-memory parked (item, occurrence) pairs and warned malformed rows. */
|
|
73
|
+
const WORKER_RUN_BIND_SKIP_LIMIT = 1_000;
|
|
74
|
+
/** Consecutive `owner-route-drift` reads of one (item, occurrence) before a single warn. */
|
|
75
|
+
const WORKER_RUN_DRIFT_WARN_AFTER = 5;
|
|
76
|
+
/** Held reports released per scheduler wake; a full page advances the in-memory round-robin cursor. */
|
|
77
|
+
const HELD_REPORT_RELEASE_LIMIT = 10;
|
|
78
|
+
/** Meta cursor: last fully-noticed Policy configuration digest. */
|
|
79
|
+
const POLICY_NOTICE_VERSION_META = 'policy.notice.version';
|
|
80
|
+
/** Bound one wake's Policy-notice paging; an incomplete pass retries next wake. */
|
|
81
|
+
const POLICY_NOTICE_PAGE_LIMIT = 10;
|
|
82
|
+
/**
|
|
83
|
+
* `bindWorkerRun` refusals that cannot change while the item keeps its
|
|
84
|
+
* occurrence: an expired, mismatched (ref, owner scope, digest), settled or
|
|
85
|
+
* non-independent contract, a changed or revoked owner route, a different
|
|
86
|
+
* frozen binding, a contract already bound elsewhere, or a malformed row.
|
|
87
|
+
* Everything else (no contract yet, missing or throwing peers) is transient.
|
|
88
|
+
*/
|
|
89
|
+
const WORKER_RUN_BIND_TERMINAL = new Set([
|
|
90
|
+
'mandate-bound', 'owner-changed', 'binding-mismatch', 'owner-route-binding-unknown',
|
|
91
|
+
'acceptance-mismatch', 'acceptance-expired', 'acceptance-already-settled', 'acceptance-not-awaiting', 'acceptance-not-independent',
|
|
92
|
+
'owner-route-invalid', 'owner-route-expired', 'identity-mismatch', 'malformed-binding',
|
|
93
|
+
]);
|
|
94
|
+
const continuationRuntimes = new WeakMap();
|
|
95
|
+
const continuationAdmissions = new WeakMap();
|
|
96
|
+
const continuationDispatches = new WeakMap();
|
|
97
|
+
const replyConsumers = new WeakMap();
|
|
98
|
+
const engineeringReaders = new WeakMap();
|
|
99
|
+
const feedbackReaders = new WeakMap();
|
|
100
|
+
const diagnosisRuntimes = new WeakMap();
|
|
101
|
+
const diagnosisAdmissions = new WeakMap();
|
|
102
|
+
/**
|
|
103
|
+
* Work-item intake kernel.
|
|
104
|
+
*
|
|
105
|
+
* This service owns configuration, the private ledger and identity resolution;
|
|
106
|
+
* it deliberately owns no agent loop, timer, outbox, budget or session. Every
|
|
107
|
+
* side effect is a call into an existing peer plugin (`assistant-automations`
|
|
108
|
+
* for the worker run, `assistant-delivery` for the owner notification), and the
|
|
109
|
+
* disabled default opens no file at all.
|
|
110
|
+
*/
|
|
111
|
+
export class AssistantWorkItemsService extends Service {
|
|
112
|
+
static Config = ConfigSchema;
|
|
113
|
+
// Cordis traces this Service through caller-context proxies. JavaScript #
|
|
114
|
+
// private brands reject those receivers; TS-private members preserve the
|
|
115
|
+
// proxy's ctx without binding methods back to the provider instance.
|
|
116
|
+
workItemsConfig;
|
|
117
|
+
workItemsEvaluation;
|
|
118
|
+
/** Host-registered followed-thread sources; empty means no follow can close. */
|
|
119
|
+
workItemsThreadSources = new Map();
|
|
120
|
+
workItemsFollowDegraded = null;
|
|
121
|
+
workItemsComponents;
|
|
122
|
+
workItemsClosed = false;
|
|
123
|
+
workItemsIntakeObserver;
|
|
124
|
+
/** Present only when `intakeLedger.evaluation` is enabled. */
|
|
125
|
+
workItemsIntakeEvaluation;
|
|
126
|
+
/** Present only when `intakeLedger.evaluation.admit` is enabled. */
|
|
127
|
+
workItemsIntakeAdmission;
|
|
128
|
+
workItemsDiagnosisKey = Object.freeze({});
|
|
129
|
+
workItemsContinuationKey = Object.freeze({});
|
|
130
|
+
workItemsReplyKey = Object.freeze({});
|
|
131
|
+
workItemsFeedbackKey = Object.freeze({});
|
|
132
|
+
workItemsDiagnosisError = null;
|
|
133
|
+
workItemsHasDiagnosisHistory = false;
|
|
134
|
+
workItemsSchedulerUnsubscribers = new Set();
|
|
135
|
+
workItemsSchedulerWaking = false;
|
|
136
|
+
workItemsSchedulerLastWakeAt = null;
|
|
137
|
+
workItemsSchedulerLastError = null;
|
|
138
|
+
/** In-memory round-robin position of `acceptance.autoSettle`; a restart rescans from the start. */
|
|
139
|
+
workItemsAutoSettleCursor = null;
|
|
140
|
+
/** In-memory round-robin position of `acceptance.bindWorkerRun`; a restart rescans from the start. */
|
|
141
|
+
workItemsBindWorkerRunCursor = null;
|
|
142
|
+
/** In-memory round-robin position of `acceptance.holdReport`; a restart rescans from the start. */
|
|
143
|
+
workItemsHeldReportCursor = null;
|
|
144
|
+
/** itemId → occurrenceId refused deterministically by `bindWorkerRun`; in memory, bounded, dropped on occurrence change. */
|
|
145
|
+
workItemsBindWorkerRunSkips = new Map();
|
|
146
|
+
/**
|
|
147
|
+
* itemId → consecutive `owner-route-drift` streak of `bindWorkerRun` for one
|
|
148
|
+
* occurrence; in memory, bounded. Any other outcome or a new occurrence
|
|
149
|
+
* drops it. Only for visibility: drift is re-read either way.
|
|
150
|
+
*/
|
|
151
|
+
workItemsBindWorkerRunDrifts = new Map();
|
|
152
|
+
/** Whether the missing Delivery `inspectOwnerRouteBinding` was already warned about. */
|
|
153
|
+
workItemsOwnerRouteBindingWarned = false;
|
|
154
|
+
/** `itemId:row` malformed `acceptance-bound` rows already warned about. */
|
|
155
|
+
workItemsMalformedBindingWarned = new Set();
|
|
156
|
+
workItemsOwnerReads;
|
|
157
|
+
workItemsOwnerContinuations;
|
|
158
|
+
constructor(ctx, input) {
|
|
159
|
+
super(ctx, 'assistantWorkItems');
|
|
160
|
+
this.workItemsConfig = Object.freeze(normalizeConfig(input, Date.now()));
|
|
161
|
+
const ownerDependencies = { config: this.workItemsConfig,
|
|
162
|
+
store: () => this.workItemsClosed ? undefined : this.workItemsComponents?.store,
|
|
163
|
+
delivery: () => this.workItemsDelivery(), policy: () => this.workItemsPolicy() };
|
|
164
|
+
const access = new WorkItemOwnerAccess(ownerDependencies);
|
|
165
|
+
const intakeEvaluation = this.workItemsConfig.enabled ? this.workItemsConfig.intakeLedger?.evaluation : undefined;
|
|
166
|
+
this.workItemsIntakeEvaluation = intakeEvaluation === undefined ? undefined : new WorkItemIntakeEvaluationRuntime({
|
|
167
|
+
config: intakeEvaluation,
|
|
168
|
+
store: () => this.workItemsClosed ? undefined : this.workItemsComponents?.store,
|
|
169
|
+
log: message => ctx.logger.debug(message),
|
|
170
|
+
});
|
|
171
|
+
this.workItemsIntakeAdmission = intakeEvaluation?.admit === undefined ? undefined : new WorkItemIntakeAdmissionBridge({
|
|
172
|
+
config: intakeEvaluation.admit,
|
|
173
|
+
maxPerRun: intakeEvaluation.maxPerRun,
|
|
174
|
+
store: () => this.workItemsClosed ? undefined : this.workItemsComponents?.store,
|
|
175
|
+
// The public Host path, so an admitted item wakes its worker like any decision.
|
|
176
|
+
admit: decision => this.admitDecision(decision),
|
|
177
|
+
log: message => ctx.logger.debug(message),
|
|
178
|
+
});
|
|
179
|
+
this.workItemsOwnerReads = new WorkItemOwnerRecall({ ...ownerDependencies, access });
|
|
180
|
+
this.workItemsOwnerContinuations = new WorkItemOwnerContinuation({ ...ownerDependencies, access,
|
|
181
|
+
runtime: () => continuationRuntimes.get(this.workItemsContinuationKey),
|
|
182
|
+
admit: (input, continuation) => {
|
|
183
|
+
const components = this.workItemsRequired(), mint = continuationAdmissions.get(components);
|
|
184
|
+
if (!mint)
|
|
185
|
+
fail('disabled', 'owner continuation admission is unavailable');
|
|
186
|
+
return mint(input, continuation);
|
|
187
|
+
} });
|
|
188
|
+
// The Evaluation outbox is service state, not ledger state: it exists even
|
|
189
|
+
// for a disabled intake so `assistant-evaluation` can bind a generation,
|
|
190
|
+
// and it never opens SQLite until an export pass actually runs.
|
|
191
|
+
this.workItemsEvaluation = new WorkItemEvaluationExporter({
|
|
192
|
+
store: () => this.workItemsComponents?.store,
|
|
193
|
+
scope: () => {
|
|
194
|
+
const scope = this.workItemsConfig.scope;
|
|
195
|
+
return scope === null ? null : Object.freeze({ workspace: scope.workspace, preset: scope.preset });
|
|
196
|
+
},
|
|
197
|
+
enabled: () => !this.workItemsClosed && isActive(this.workItemsConfig) && this.workItemsConfig.settlement.enabled,
|
|
198
|
+
now: () => Date.now(),
|
|
199
|
+
warn: message => this.ctx.logger.warn(message),
|
|
200
|
+
});
|
|
201
|
+
ctx.effect(() => () => this.close(), 'assistant-work-items.store');
|
|
202
|
+
ctx.inject(['tools'], toolsCtx => {
|
|
203
|
+
registerWorkItemTools(toolsCtx, this);
|
|
204
|
+
if (this.workItemsConfig.ownerContinuation) {
|
|
205
|
+
// The pinned native guard runs after extensible approval listeners and
|
|
206
|
+
// cannot be overridden by a later allow. Cover every tool at native
|
|
207
|
+
// dispatch preparation; the exclusive report tool is a scheduling barrier.
|
|
208
|
+
toolsCtx.tools.guard(execution => {
|
|
209
|
+
const context = execution.agent?.ctx.get('assistantAutomationExecution');
|
|
210
|
+
if (context?.automationId !== this.workItemsConfig.ownerContinuation.automationId)
|
|
211
|
+
return undefined;
|
|
212
|
+
try {
|
|
213
|
+
const occurrenceId = this.workItemsOccurrenceId(execution.agent);
|
|
214
|
+
const runtime = continuationRuntimes.get(this.workItemsContinuationKey);
|
|
215
|
+
if (!runtime)
|
|
216
|
+
return 'continuation invocation is unavailable';
|
|
217
|
+
runtime.assertTool(occurrenceId, execution.name);
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return 'continuation tool authority or execution phase changed';
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
// The disabled default must not create a directory, lock a file or open a
|
|
227
|
+
// database, so persistence starts only for an explicitly enabled intake.
|
|
228
|
+
if (this.workItemsConfig.enabled)
|
|
229
|
+
this.workItemsOpen();
|
|
230
|
+
if (this.workItemsConfig.enabled && this.workItemsConfig.intakeLedger) {
|
|
231
|
+
// WP20 slice 2: inbound adapters reach the ledger only through Delivery's
|
|
232
|
+
// metadata-only observation. Observe and active modes behave the same;
|
|
233
|
+
// the observer writes the intake ledger and nothing else.
|
|
234
|
+
ctx.inject(['assistantDelivery'], intakeCtx => {
|
|
235
|
+
if (this.workItemsClosed)
|
|
236
|
+
return;
|
|
237
|
+
const peer = intakeCtx.get('assistantDelivery');
|
|
238
|
+
if (typeof peer?.registerInboundIntakeObserver !== 'function') {
|
|
239
|
+
intakeCtx.logger.warn('assistant-work-items: Delivery inbound intake observation is unavailable; the intake ledger stays Host-only');
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const evaluation = this.workItemsIntakeEvaluation;
|
|
243
|
+
const observer = Object.freeze({ protocol: 1, observe: (signal) => {
|
|
244
|
+
if (this.workItemsClosed || this.workItemsIntakeObserver !== observer)
|
|
245
|
+
return;
|
|
246
|
+
try {
|
|
247
|
+
if (evaluation === undefined) {
|
|
248
|
+
this.recordIntake(signal);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
// The transient text never reaches the ledger: it is split off here and only hashed or held in memory.
|
|
252
|
+
const { sourceText, ...metadata } = signal;
|
|
253
|
+
this.recordIntake(metadata, typeof sourceText === 'string' ? sourceText : undefined);
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
intakeCtx.logger.debug(`assistant-work-items: intake observation not recorded: ${describe(error)}`);
|
|
257
|
+
}
|
|
258
|
+
}, ...(evaluation === undefined ? {} : { acceptsSourceText: true }) });
|
|
259
|
+
this.workItemsIntakeObserver = observer;
|
|
260
|
+
let unregister;
|
|
261
|
+
try {
|
|
262
|
+
unregister = peer.registerInboundIntakeObserver(observer);
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
this.workItemsIntakeObserver = undefined;
|
|
266
|
+
intakeCtx.logger.warn(`assistant-work-items: inbound intake observer unavailable: ${describe(error)}`);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
intakeCtx.effect(() => () => {
|
|
270
|
+
unregister();
|
|
271
|
+
if (this.workItemsIntakeObserver === observer)
|
|
272
|
+
this.workItemsIntakeObserver = undefined;
|
|
273
|
+
}, 'assistant-work-items.intake-observer');
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
if (this.workItemsConfig.enabled && this.workItemsConfig.follows?.eventTriggers === true) {
|
|
277
|
+
// Subscription pass-through: Event Triggers becomes a followed-thread
|
|
278
|
+
// source. Resolution is passive (settlement pass only); a re-provided
|
|
279
|
+
// Event Triggers service re-runs this callback with a fresh generation.
|
|
280
|
+
ctx.inject(['eventTriggers', 'assistantDelivery'], followCtx => {
|
|
281
|
+
if (this.workItemsClosed)
|
|
282
|
+
return;
|
|
283
|
+
const reader = eventFollowReader(followCtx.get('eventTriggers'));
|
|
284
|
+
const scope = this.workItemsConfig.scope;
|
|
285
|
+
if (reader === undefined || scope === null) {
|
|
286
|
+
followCtx.logger.warn('assistant-work-items: Event Triggers source reads are unavailable; event follows stay unregistered');
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
let unregister;
|
|
290
|
+
try {
|
|
291
|
+
unregister = this.registerThreadSource(eventFollowRegistration({
|
|
292
|
+
reader, owner: () => this.workItemsEventFollowOwner(scope),
|
|
293
|
+
acceptHeadRef: this.workItemsConfig.follows?.eventTriggersAcceptHeadRef === true,
|
|
294
|
+
}));
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
followCtx.logger.warn(`assistant-work-items: event follow source unavailable: ${describe(error)}`);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
followCtx.effect(() => unregister, 'assistant-work-items.event-follows');
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
// Evaluation reuses the native scheduler wake; without it nothing new subscribes.
|
|
304
|
+
if (isActive(this.workItemsConfig) || this.workItemsIntakeEvaluation !== undefined) {
|
|
305
|
+
ctx.inject(['assistantAutomations'], wakeCtx => {
|
|
306
|
+
if (this.workItemsClosed)
|
|
307
|
+
return;
|
|
308
|
+
const peer = wakeCtx.get('assistantAutomations');
|
|
309
|
+
if (typeof peer?.onSchedulerWake !== 'function') {
|
|
310
|
+
wakeCtx.logger.warn('assistant-work-items: native scheduler wake is unavailable; waiting resumes retain startup/claim recovery');
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
// Resolve on the consumer Context so Automations owns this effect on
|
|
314
|
+
// the injecting Fiber, not on its own provider or a cached root proxy.
|
|
315
|
+
let active = true;
|
|
316
|
+
const unregister = peer.onSchedulerWake(() => {
|
|
317
|
+
if (!active)
|
|
318
|
+
return;
|
|
319
|
+
this.workItemsOnSchedulerWake();
|
|
320
|
+
if (!this.workItemsClosed)
|
|
321
|
+
void this.workItemsIntakeEvaluation?.run().then(() => this.runIntakeAdmission());
|
|
322
|
+
});
|
|
323
|
+
const remove = () => {
|
|
324
|
+
if (!active)
|
|
325
|
+
return;
|
|
326
|
+
active = false;
|
|
327
|
+
this.workItemsSchedulerUnsubscribers.delete(remove);
|
|
328
|
+
unregister();
|
|
329
|
+
};
|
|
330
|
+
if (this.workItemsClosed) {
|
|
331
|
+
remove();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
this.workItemsSchedulerUnsubscribers.add(remove);
|
|
335
|
+
wakeCtx.effect(() => remove, 'assistant-work-items.scheduler-wake');
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
if (isActive(this.workItemsConfig) && this.workItemsConfig.diagnosis) {
|
|
339
|
+
ctx.inject(['assistantAutomations', 'assistantDelivery', 'tools'], diagnosisCtx => {
|
|
340
|
+
if (this.workItemsClosed)
|
|
341
|
+
return;
|
|
342
|
+
const components = this.workItemsOpen();
|
|
343
|
+
const mint = diagnosisAdmissions.get(components);
|
|
344
|
+
if (!mint)
|
|
345
|
+
fail('storage', 'private diagnosis admission is unavailable');
|
|
346
|
+
const runtime = new WorkItemDiagnosis(diagnosisCtx, this.workItemsConfig, components.store, components.admission, components.dispatch, {
|
|
347
|
+
admit: mint,
|
|
348
|
+
assertOwner: item => {
|
|
349
|
+
const receipt = this.workItemsAssertOwnerRoute(item);
|
|
350
|
+
if (receipt.principalVersion !== item.owner.principalVersion || receipt.bindingVersion !== item.owner.bindingVersion
|
|
351
|
+
|| receipt.generation !== item.owner.generation)
|
|
352
|
+
fail('owner-route-invalid', 'diagnosis owner generation changed');
|
|
353
|
+
return receipt;
|
|
354
|
+
},
|
|
355
|
+
assertMandate: item => {
|
|
356
|
+
const decision = components.authority.check(item);
|
|
357
|
+
if (decision.kind !== 'allow')
|
|
358
|
+
fail('authority-revoked', 'diagnosis admission mandate is unavailable');
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
diagnosisRuntimes.set(this.workItemsDiagnosisKey, runtime);
|
|
362
|
+
try {
|
|
363
|
+
runtime.start();
|
|
364
|
+
this.workItemsDiagnosisError = null;
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
runtime.close();
|
|
368
|
+
if (diagnosisRuntimes.get(this.workItemsDiagnosisKey) === runtime)
|
|
369
|
+
diagnosisRuntimes.delete(this.workItemsDiagnosisKey);
|
|
370
|
+
this.workItemsDiagnosisError = 'diagnosis-activation-unavailable';
|
|
371
|
+
diagnosisCtx.logger.warn(`assistant-work-items: diagnosis activation unavailable: ${describe(error)}`);
|
|
372
|
+
}
|
|
373
|
+
diagnosisCtx.effect(() => () => {
|
|
374
|
+
runtime.close();
|
|
375
|
+
if (diagnosisRuntimes.get(this.workItemsDiagnosisKey) === runtime)
|
|
376
|
+
diagnosisRuntimes.delete(this.workItemsDiagnosisKey);
|
|
377
|
+
}, 'assistant-work-items.diagnosis');
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
if (isActive(this.workItemsConfig) && this.workItemsConfig.ownerContinuation) {
|
|
381
|
+
ctx.inject(['assistantAutomations', 'assistantDelivery', 'tools'], continuationCtx => {
|
|
382
|
+
if (this.workItemsClosed)
|
|
383
|
+
return;
|
|
384
|
+
const components = this.workItemsOpen(), dispatch = continuationDispatches.get(components);
|
|
385
|
+
if (!dispatch)
|
|
386
|
+
fail('storage', 'continuation dispatch is unavailable');
|
|
387
|
+
const runtime = new WorkItemContinuation(continuationCtx, this.workItemsConfig, components.store, {
|
|
388
|
+
assertOwner: item => {
|
|
389
|
+
const receipt = this.workItemsAssertOwnerRoute(item);
|
|
390
|
+
if (receipt.principalVersion !== item.owner.principalVersion || receipt.bindingVersion !== item.owner.bindingVersion
|
|
391
|
+
|| receipt.generation !== item.owner.generation)
|
|
392
|
+
fail('owner-route-invalid', 'continuation owner generation changed');
|
|
393
|
+
return receipt;
|
|
394
|
+
},
|
|
395
|
+
assertMandate: item => {
|
|
396
|
+
if (components.authority.check(item).kind !== 'allow')
|
|
397
|
+
fail('authority-revoked', 'continuation admission mandate is unavailable');
|
|
398
|
+
},
|
|
399
|
+
wake: item => dispatch.wake(item),
|
|
400
|
+
isReplyConsumerCurrent: () => !this.workItemsClosed && replyConsumers.get(this.workItemsReplyKey)?.isCurrent() === true,
|
|
401
|
+
});
|
|
402
|
+
continuationRuntimes.set(this.workItemsContinuationKey, runtime);
|
|
403
|
+
try {
|
|
404
|
+
runtime.start();
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
runtime.close();
|
|
408
|
+
if (continuationRuntimes.get(this.workItemsContinuationKey) === runtime)
|
|
409
|
+
continuationRuntimes.delete(this.workItemsContinuationKey);
|
|
410
|
+
continuationCtx.logger.warn(`assistant-work-items: continuation activation unavailable: ${describe(error)}`);
|
|
411
|
+
}
|
|
412
|
+
continuationCtx.effect(() => () => {
|
|
413
|
+
runtime.close();
|
|
414
|
+
if (continuationRuntimes.get(this.workItemsContinuationKey) === runtime)
|
|
415
|
+
continuationRuntimes.delete(this.workItemsContinuationKey);
|
|
416
|
+
}, 'assistant-work-items.continuation');
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
if (isActive(this.workItemsConfig) && (this.workItemsConfig.ownerReplyContinuation || this.workItemsConfig.ownerReportFeedback)) {
|
|
420
|
+
// Historical proof/report metadata survive an expired execution grant,
|
|
421
|
+
// a paused worker or an absent tools catalog. This owned read fiber is
|
|
422
|
+
// deliberately separate from continuation runtime.start().
|
|
423
|
+
ctx.inject(['assistantDelivery', 'assistantAutomations'], reportCtx => {
|
|
424
|
+
if (this.workItemsClosed)
|
|
425
|
+
return;
|
|
426
|
+
const components = this.workItemsOpen();
|
|
427
|
+
let reader;
|
|
428
|
+
let engineering;
|
|
429
|
+
if (this.workItemsConfig.ownerReportFeedback) {
|
|
430
|
+
const current = new WorkItemOwnerFeedbackTargets({ config: this.workItemsConfig,
|
|
431
|
+
store: () => this.workItemsClosed ? undefined : components.store,
|
|
432
|
+
delivery: () => reportCtx.get('assistantDelivery'),
|
|
433
|
+
automations: () => reportCtx.get('assistantAutomations'),
|
|
434
|
+
current: () => !this.workItemsClosed && feedbackReaders.get(this.workItemsFeedbackKey) === current });
|
|
435
|
+
reader = current;
|
|
436
|
+
feedbackReaders.set(this.workItemsFeedbackKey, current);
|
|
437
|
+
const source = new WorkItemEngineeringTargets({ config: this.workItemsConfig,
|
|
438
|
+
store: () => this.workItemsClosed ? undefined : components.store,
|
|
439
|
+
feedback: () => feedbackReaders.get(this.workItemsFeedbackKey),
|
|
440
|
+
current: () => !this.workItemsClosed && engineeringReaders.get(this.workItemsFeedbackKey) === source });
|
|
441
|
+
engineering = source;
|
|
442
|
+
engineeringReaders.set(this.workItemsFeedbackKey, source);
|
|
443
|
+
}
|
|
444
|
+
const replies = new WorkItemOwnerReplies({ config: this.workItemsConfig, store: components.store,
|
|
445
|
+
delivery: () => reportCtx.get('assistantDelivery'),
|
|
446
|
+
runtime: () => continuationRuntimes.get(this.workItemsContinuationKey),
|
|
447
|
+
admit: (input, continuation) => {
|
|
448
|
+
const mint = continuationAdmissions.get(components);
|
|
449
|
+
if (!mint || this.workItemsClosed)
|
|
450
|
+
fail('disabled', 'reply continuation admission unavailable');
|
|
451
|
+
return mint(input, continuation);
|
|
452
|
+
}, notification: (item, report) => components.dispatch.notification(item, report),
|
|
453
|
+
...(reader ? { inspectFeedbackReport: input => reader?.inspectReport(input, (item, report) => components.dispatch.notification(item, report)) } : {}),
|
|
454
|
+
retire: ids => {
|
|
455
|
+
for (const id of ids) {
|
|
456
|
+
const item = components.store.findItemById(id);
|
|
457
|
+
if (item && components.store.getContinuation(id))
|
|
458
|
+
components.store.withdrawByDedupKey(item.dedupKey);
|
|
459
|
+
}
|
|
460
|
+
continuationRuntimes.get(this.workItemsContinuationKey)?.retire(ids);
|
|
461
|
+
} });
|
|
462
|
+
replyConsumers.set(this.workItemsReplyKey, replies);
|
|
463
|
+
try {
|
|
464
|
+
replies.start();
|
|
465
|
+
}
|
|
466
|
+
catch (error) {
|
|
467
|
+
replies.close();
|
|
468
|
+
reportCtx.logger.warn(`assistant-work-items: report consumer unavailable: ${describe(error)}`);
|
|
469
|
+
}
|
|
470
|
+
reportCtx.effect(() => () => {
|
|
471
|
+
engineering?.close();
|
|
472
|
+
if (engineeringReaders.get(this.workItemsFeedbackKey) === engineering)
|
|
473
|
+
engineeringReaders.delete(this.workItemsFeedbackKey);
|
|
474
|
+
reader?.close();
|
|
475
|
+
if (feedbackReaders.get(this.workItemsFeedbackKey) === reader)
|
|
476
|
+
feedbackReaders.delete(this.workItemsFeedbackKey);
|
|
477
|
+
replies.close();
|
|
478
|
+
if (replyConsumers.get(this.workItemsReplyKey) === replies)
|
|
479
|
+
replyConsumers.delete(this.workItemsReplyKey);
|
|
480
|
+
}, 'assistant-work-items.owner-report-targets');
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
/** One native wake may admit at most ten already-due waiting occurrences. */
|
|
485
|
+
workItemsOnSchedulerWake() {
|
|
486
|
+
if (this.workItemsClosed || !isActive(this.workItemsConfig) || this.workItemsSchedulerWaking)
|
|
487
|
+
return;
|
|
488
|
+
const components = this.workItemsComponents;
|
|
489
|
+
if (components === undefined)
|
|
490
|
+
return;
|
|
491
|
+
this.workItemsSchedulerWaking = true;
|
|
492
|
+
this.workItemsSchedulerLastWakeAt = Date.now();
|
|
493
|
+
this.workItemsSchedulerLastError = null;
|
|
494
|
+
try {
|
|
495
|
+
for (const selected of components.store.listDueWaitingResumes(10)) {
|
|
496
|
+
if (this.workItemsClosed)
|
|
497
|
+
break;
|
|
498
|
+
// Advance before examining authority, including on denied items. The
|
|
499
|
+
// next tick can reach later rows; pending rows remain available on wrap.
|
|
500
|
+
components.store.advanceWaitingResumeCursor(selected);
|
|
501
|
+
try {
|
|
502
|
+
const dispatch = components.store.getDispatch(selected.itemId);
|
|
503
|
+
const item = components.store.findItemById(selected.itemId);
|
|
504
|
+
const scope = this.workItemsConfig.scope;
|
|
505
|
+
if (dispatch === undefined || dispatch.state !== 'pending' || dispatch.reason !== 'waiting-resume'
|
|
506
|
+
|| dispatch.eventId !== selected.eventId || dispatch.generation !== selected.generation
|
|
507
|
+
|| dispatch.occurredAt > Date.now() || item === undefined || item.state !== 'waiting'
|
|
508
|
+
|| item.lastReportStatus !== 'waiting' || item.observed || item.leaseUntil !== null
|
|
509
|
+
|| components.store.activeClaim(item.id) !== undefined || item.attempt >= item.maxAttempts
|
|
510
|
+
|| item.authority === null || scope === null || scope.expiresAt <= Date.now()
|
|
511
|
+
|| item.scope.workspace !== scope.workspace || item.scope.preset !== scope.preset
|
|
512
|
+
|| item.scope.principalId !== scope.principalId || item.scope.ownerRouteId !== scope.ownerRouteId)
|
|
513
|
+
continue;
|
|
514
|
+
const owner = this.workItemsAssertOwnerRoute(item);
|
|
515
|
+
if (owner.principalVersion !== item.owner.principalVersion
|
|
516
|
+
|| owner.bindingVersion !== item.owner.bindingVersion || owner.generation !== item.owner.generation)
|
|
517
|
+
continue;
|
|
518
|
+
// Reuse the existing mandate check, stable event identity, ingestion
|
|
519
|
+
// Policy and unknown-outcome handling. This does not run an Agent.
|
|
520
|
+
components.dispatch.wake(item);
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
this.workItemsSchedulerLastError = error instanceof WorkItemError ? error.code : 'scheduler-wake-failed';
|
|
524
|
+
this.ctx.logger.warn(`assistant-work-items: waiting resume wake failed: ${describe(error)}`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (this.workItemsConfig.diagnosis)
|
|
528
|
+
diagnosisRuntimes.get(this.workItemsDiagnosisKey)?.sweep(false);
|
|
529
|
+
replyConsumers.get(this.workItemsReplyKey)?.drain();
|
|
530
|
+
continuationRuntimes.get(this.workItemsContinuationKey)?.wakePending();
|
|
531
|
+
}
|
|
532
|
+
catch (error) {
|
|
533
|
+
this.workItemsSchedulerLastError = error instanceof WorkItemError ? error.code : 'scheduler-wake-failed';
|
|
534
|
+
this.ctx.logger.warn(`assistant-work-items: waiting resume scan failed: ${describe(error)}`);
|
|
535
|
+
}
|
|
536
|
+
finally {
|
|
537
|
+
// Bind first so a run that already finished can settle on this same wake.
|
|
538
|
+
try {
|
|
539
|
+
this.workItemsBindWorkerRuns(components);
|
|
540
|
+
}
|
|
541
|
+
catch (error) {
|
|
542
|
+
this.ctx.logger.warn(`assistant-work-items: acceptance worker-run bind scan failed: ${describe(error)}`);
|
|
543
|
+
}
|
|
544
|
+
try {
|
|
545
|
+
this.workItemsAutoSettle(components);
|
|
546
|
+
}
|
|
547
|
+
catch (error) {
|
|
548
|
+
this.ctx.logger.warn(`assistant-work-items: acceptance auto-settle scan failed: ${describe(error)}`);
|
|
549
|
+
}
|
|
550
|
+
// P1-1: resolve outcome windows and followed-thread closures on the same
|
|
551
|
+
// bounded wake instead of waiting for an unrelated claim or Host call.
|
|
552
|
+
this.workItemsSettleQuietly(components);
|
|
553
|
+
// Release only after auto-settle, so a verdict that arrived on this wake
|
|
554
|
+
// decides the same pass whether the held report is sent or blocked.
|
|
555
|
+
try {
|
|
556
|
+
this.workItemsReleaseHeldReports(components);
|
|
557
|
+
}
|
|
558
|
+
catch (error) {
|
|
559
|
+
this.ctx.logger.warn(`assistant-work-items: acceptance held-report scan failed: ${describe(error)}`);
|
|
560
|
+
}
|
|
561
|
+
// P1-1: materialize terminal follow-up releases on the same bounded wake.
|
|
562
|
+
// The store derives them from durable state, so this is restart-safe and a
|
|
563
|
+
// second pass releases nothing new. Delivery still owns the authenticated
|
|
564
|
+
// reply text and decides whether a released follow-up re-enters judgment.
|
|
565
|
+
try {
|
|
566
|
+
const released = components.store.releaseEndedFollowUps(100);
|
|
567
|
+
if (released.length > 0) {
|
|
568
|
+
this.ctx.logger.debug(`assistant-work-items: released ${released.length} ended follow-up(s)`);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
catch (error) {
|
|
572
|
+
this.ctx.logger.warn(`assistant-work-items: terminal follow-up release scan failed: ${describe(error)}`);
|
|
573
|
+
}
|
|
574
|
+
try {
|
|
575
|
+
this.workItemsNoticePolicyChange(components);
|
|
576
|
+
}
|
|
577
|
+
catch (error) {
|
|
578
|
+
this.ctx.logger.warn(`assistant-work-items: policy notice scan failed: ${describe(error)}`);
|
|
579
|
+
}
|
|
580
|
+
this.workItemsSchedulerWaking = false;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* P1-1: a changed Policy configuration notices every live item whose frozen
|
|
585
|
+
* mandate basis no longer matches. The digest is read from the detached Policy
|
|
586
|
+
* host snapshot; no Policy audit or budget is consumed. Notices are durable
|
|
587
|
+
* and idempotent, and this path never cancels, parks or wakes an item.
|
|
588
|
+
*/
|
|
589
|
+
workItemsNoticePolicyChange(components) {
|
|
590
|
+
const version = components.authority.policyVersion();
|
|
591
|
+
if (version === null || components.store.meta(POLICY_NOTICE_VERSION_META) === version)
|
|
592
|
+
return;
|
|
593
|
+
let after;
|
|
594
|
+
for (let page = 0; page < POLICY_NOTICE_PAGE_LIMIT; page += 1) {
|
|
595
|
+
const result = noticePolicyChange(components.store, components.authority, {
|
|
596
|
+
protocol: 'work-items/policy-change/v1', policyVersion: version,
|
|
597
|
+
...(after === undefined ? {} : { after }),
|
|
598
|
+
});
|
|
599
|
+
if (result.outcome !== 'noticed')
|
|
600
|
+
return;
|
|
601
|
+
if (result.next === null) {
|
|
602
|
+
// A transient Policy failure leaves affected items unassessed. Do not
|
|
603
|
+
// record the version as complete: the next wake must retry the same
|
|
604
|
+
// configuration so those items still get their notices after recovery.
|
|
605
|
+
if (result.unavailable.length > 0)
|
|
606
|
+
return;
|
|
607
|
+
components.store.setMeta(POLICY_NOTICE_VERSION_META, version);
|
|
608
|
+
if (result.notified.length > 0) {
|
|
609
|
+
this.ctx.logger.debug(`assistant-work-items: recorded ${result.notified.length} Policy-change notice(s)`);
|
|
610
|
+
}
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
after = result.next;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* WP21 slice 6: on the same native wake, bind at most `maxPerTick`
|
|
618
|
+
* dispatched items to the acceptance contract their own worker run froze.
|
|
619
|
+
* Automations only names the handle for the exact occurrence; the contract
|
|
620
|
+
* itself is re-read from the verifier and checked against the live owner
|
|
621
|
+
* route. Items whose mandate already binds a contract are never touched.
|
|
622
|
+
* Round-robin like auto-settle. A deterministic refusal parks the
|
|
623
|
+
* (item, occurrence) pair in memory so it is not re-read until the item's
|
|
624
|
+
* occurrence changes; transient ones (no contract yet, peer missing or
|
|
625
|
+
* throwing, Delivery failing other than `missing-binding`) are simply
|
|
626
|
+
* tried again on a later wake. Parked pairs do not consume the per-wake budget.
|
|
627
|
+
*/
|
|
628
|
+
workItemsBindWorkerRuns(components) {
|
|
629
|
+
const bind = this.workItemsConfig.acceptanceBindWorkerRun;
|
|
630
|
+
const worker = this.workItemsConfig.workerAutomationId;
|
|
631
|
+
const automations = this.workItemsAutomations();
|
|
632
|
+
if (bind === undefined || worker === null || this.workItemsClosed || this.workItemsVerifier() === undefined
|
|
633
|
+
|| typeof automations?.inspectOccurrenceAcceptance !== 'function')
|
|
634
|
+
return;
|
|
635
|
+
const limit = bind.maxPerTick;
|
|
636
|
+
const skips = this.workItemsBindWorkerRunSkips;
|
|
637
|
+
const seen = new Set();
|
|
638
|
+
let processed = 0, scanned = 0, wrapped = this.workItemsBindWorkerRunCursor === null, done = false;
|
|
639
|
+
while (!done && processed < limit && scanned < WORKER_RUN_BIND_SCAN_LIMIT && !this.workItemsClosed) {
|
|
640
|
+
const page = components.store.listLateAcceptanceCandidates(worker, this.workItemsBindWorkerRunCursor, 10);
|
|
641
|
+
if (page.length === 0) {
|
|
642
|
+
if (wrapped)
|
|
643
|
+
break;
|
|
644
|
+
wrapped = true;
|
|
645
|
+
this.workItemsBindWorkerRunCursor = null;
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
for (const candidate of page) {
|
|
649
|
+
if (processed >= limit || scanned >= WORKER_RUN_BIND_SCAN_LIMIT || this.workItemsClosed) {
|
|
650
|
+
done = true;
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
const { item, occurrenceId } = candidate;
|
|
654
|
+
if (seen.has(item.id)) {
|
|
655
|
+
done = true;
|
|
656
|
+
break;
|
|
657
|
+
}
|
|
658
|
+
seen.add(item.id);
|
|
659
|
+
scanned += 1;
|
|
660
|
+
this.workItemsBindWorkerRunCursor = Object.freeze({ createdAt: item.createdAt, id: item.id });
|
|
661
|
+
const parked = skips.get(item.id);
|
|
662
|
+
if (parked === occurrenceId)
|
|
663
|
+
continue;
|
|
664
|
+
if (parked !== undefined)
|
|
665
|
+
skips.delete(item.id);
|
|
666
|
+
processed += 1;
|
|
667
|
+
let outcome;
|
|
668
|
+
try {
|
|
669
|
+
outcome = this.workItemsBindWorkerRun(components, automations, candidate);
|
|
670
|
+
}
|
|
671
|
+
catch (error) {
|
|
672
|
+
if (this.workItemsWarnMalformedLateBinding(item.id, error))
|
|
673
|
+
outcome = 'malformed-binding';
|
|
674
|
+
else {
|
|
675
|
+
outcome = error instanceof WorkItemError ? error.code : 'error';
|
|
676
|
+
this.ctx.logger.debug(`assistant-work-items: acceptance worker-run bind for ${item.id} not recorded: ${describe(error)}`);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
this.workItemsTrackBindWorkerRunDrift(item.id, occurrenceId, outcome);
|
|
680
|
+
if (WORKER_RUN_BIND_TERMINAL.has(outcome)) {
|
|
681
|
+
// Bounded: the oldest parked pair is forgotten first and simply re-read once.
|
|
682
|
+
if (skips.size >= WORKER_RUN_BIND_SKIP_LIMIT)
|
|
683
|
+
skips.delete(skips.keys().next().value);
|
|
684
|
+
skips.set(item.id, occurrenceId);
|
|
685
|
+
}
|
|
686
|
+
if (outcome !== 'bound' && outcome !== 'no-contract') {
|
|
687
|
+
this.ctx.logger.debug(`assistant-work-items: acceptance worker-run bind for ${item.id}: ${outcome}`);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Count consecutive `owner-route-drift` outcomes of one (item, occurrence)
|
|
694
|
+
* and warn once when the streak reaches `WORKER_RUN_DRIFT_WARN_AFTER`: a
|
|
695
|
+
* route that keeps moving between the two Delivery reads would otherwise be
|
|
696
|
+
* re-read silently forever. Any other outcome, including a successful bind,
|
|
697
|
+
* or a new occurrence restarts the streak. Never changes the re-read.
|
|
698
|
+
*/
|
|
699
|
+
workItemsTrackBindWorkerRunDrift(itemId, occurrenceId, outcome) {
|
|
700
|
+
const drifts = this.workItemsBindWorkerRunDrifts;
|
|
701
|
+
if (outcome !== 'owner-route-drift') {
|
|
702
|
+
drifts.delete(itemId);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
const previous = drifts.get(itemId);
|
|
706
|
+
const count = previous?.occurrenceId === occurrenceId ? previous.count + 1 : 1;
|
|
707
|
+
if (previous === undefined && drifts.size >= WORKER_RUN_BIND_SKIP_LIMIT)
|
|
708
|
+
drifts.delete(drifts.keys().next().value);
|
|
709
|
+
drifts.set(itemId, { occurrenceId, count });
|
|
710
|
+
if (count === WORKER_RUN_DRIFT_WARN_AFTER) {
|
|
711
|
+
this.ctx.logger.warn(`assistant-work-items: work item ${itemId} occurrence ${occurrenceId} saw owner-route-drift on ${String(count)} consecutive bindWorkerRun reads; still re-reading, the owner route keeps moving between validateOwnerRoute and inspectOwnerRouteBinding`);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Warn once per (item, row) about a malformed latest `acceptance-bound`
|
|
716
|
+
* row. The item stays fail-closed (never bound or settled); later reads of
|
|
717
|
+
* the same row stay silent. Returns whether `error` was that condition.
|
|
718
|
+
*/
|
|
719
|
+
workItemsWarnMalformedLateBinding(itemId, error) {
|
|
720
|
+
if (!(error instanceof WorkItemError) || error.code !== 'storage' || error.message !== MALFORMED_ACCEPTANCE_BINDING)
|
|
721
|
+
return false;
|
|
722
|
+
const key = `${itemId}:${error.detail ?? ''}`;
|
|
723
|
+
if (this.workItemsMalformedBindingWarned.has(key))
|
|
724
|
+
return true;
|
|
725
|
+
if (this.workItemsMalformedBindingWarned.size >= WORKER_RUN_BIND_SKIP_LIMIT)
|
|
726
|
+
this.workItemsMalformedBindingWarned.clear();
|
|
727
|
+
this.workItemsMalformedBindingWarned.add(key);
|
|
728
|
+
this.ctx.logger.warn(`assistant-work-items: work item ${itemId} has a malformed ${error.detail ?? 'acceptance-bound row'}; its acceptance stays unbound and unsettled`);
|
|
729
|
+
return true;
|
|
730
|
+
}
|
|
731
|
+
workItemsBindWorkerRun(components, automations, candidate) {
|
|
732
|
+
const { item, automationId, occurrenceId, notBefore } = candidate;
|
|
733
|
+
if (item.authority?.mandate?.acceptance !== undefined)
|
|
734
|
+
return 'mandate-bound';
|
|
735
|
+
if (typeof automations.inspectOccurrenceAcceptance !== 'function')
|
|
736
|
+
return 'automations-unsupported';
|
|
737
|
+
const handle = automations.inspectOccurrenceAcceptance({ automationId, occurrenceId, workspace: item.scope.workspace, agentPreset: item.scope.preset });
|
|
738
|
+
if (handle === undefined)
|
|
739
|
+
return 'no-contract';
|
|
740
|
+
// The contract owner must be this item's owner on the live route; any
|
|
741
|
+
// drift since admission leaves the item unbound. Delivery's
|
|
742
|
+
// `missing-binding` (`owner-route-invalid`) is deterministic for this
|
|
743
|
+
// item: a revoked binding never becomes active again and every re-link or
|
|
744
|
+
// `/new` gets a strictly higher generation, so the generation frozen at
|
|
745
|
+
// admission cannot come back. Other Delivery failures
|
|
746
|
+
// (`owner-route-unavailable`) stay transient and are re-read.
|
|
747
|
+
const owner = this.workItemsAssertOwnerRoute(item);
|
|
748
|
+
const route = this.workItemsOwnerRouteBindingId(item, owner);
|
|
749
|
+
if (owner.principalVersion !== item.owner.principalVersion
|
|
750
|
+
|| owner.bindingVersion !== item.owner.bindingVersion || owner.generation !== item.owner.generation)
|
|
751
|
+
return 'owner-changed';
|
|
752
|
+
// A Delivery without the method, or a route that moved between the two
|
|
753
|
+
// reads, is re-read on a later wake instead of parking the pair; only a
|
|
754
|
+
// malformed answer is final for this occurrence.
|
|
755
|
+
if (route.kind !== 'bound') {
|
|
756
|
+
return route.kind === 'unsupported' ? 'owner-route-binding-unsupported'
|
|
757
|
+
: route.kind === 'drift' ? 'owner-route-drift' : 'owner-route-binding-unknown';
|
|
758
|
+
}
|
|
759
|
+
// The run must have been frozen under exactly this owner route's binding.
|
|
760
|
+
const bindingId = route.bindingId;
|
|
761
|
+
if (typeof handle.bindingId !== 'string' || handle.bindingId !== bindingId
|
|
762
|
+
|| handle.bindingVersion !== owner.bindingVersion || handle.bindingGeneration !== owner.generation)
|
|
763
|
+
return 'binding-mismatch';
|
|
764
|
+
const binding = resolveAcceptanceBinding(this.workItemsVerifier(), handle, owner, Date.now(), { taskRef: workerRunTaskRef(occurrenceId), notBefore });
|
|
765
|
+
if (typeof binding === 'string')
|
|
766
|
+
return binding;
|
|
767
|
+
return components.store.recordLateAcceptanceBinding({ itemId: item.id, occurrenceId, binding }).created ? 'bound' : 'already-bound';
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* The exact Delivery binding id behind `owner`, read through Delivery's
|
|
771
|
+
* Host-only `inspectOwnerRouteBinding`. Its embedded receipt must equal
|
|
772
|
+
* `owner` field for field, so the id belongs to the very route state just
|
|
773
|
+
* validated. Fails closed without a binding id when Delivery lacks the
|
|
774
|
+
* method (`unsupported`), when the embedded receipt differs from `owner`
|
|
775
|
+
* (`drift`: the route moved between the two reads) or when the answer is
|
|
776
|
+
* malformed; Delivery errors propagate like `validateOwnerRoute`'s.
|
|
777
|
+
*/
|
|
778
|
+
workItemsOwnerRouteBindingId(item, owner) {
|
|
779
|
+
const delivery = this.workItemsDelivery();
|
|
780
|
+
if (typeof delivery?.inspectOwnerRouteBinding !== 'function') {
|
|
781
|
+
if (!this.workItemsOwnerRouteBindingWarned) {
|
|
782
|
+
this.workItemsOwnerRouteBindingWarned = true;
|
|
783
|
+
this.ctx.logger.warn('assistant-work-items: acceptance.bindWorkerRun needs assistant-delivery with inspectOwnerRouteBinding; worker runs stay unbound');
|
|
784
|
+
}
|
|
785
|
+
return { kind: 'unsupported' };
|
|
786
|
+
}
|
|
787
|
+
// Warn again if a later Delivery drops the method once more.
|
|
788
|
+
this.workItemsOwnerRouteBindingWarned = false;
|
|
789
|
+
const scope = item.scope;
|
|
790
|
+
let read;
|
|
791
|
+
try {
|
|
792
|
+
read = delivery.inspectOwnerRouteBinding({ authorityId: scope.ownerRouteId, principalId: scope.principalId,
|
|
793
|
+
workspace: scope.workspace, agentPreset: scope.preset });
|
|
794
|
+
}
|
|
795
|
+
catch (error) {
|
|
796
|
+
const missing = error !== null && typeof error === 'object' && 'code' in error && error.code === 'missing-binding';
|
|
797
|
+
fail(missing ? 'owner-route-invalid' : 'owner-route-unavailable', 'owner route binding read failed', error instanceof Error ? error.message : String(error));
|
|
798
|
+
}
|
|
799
|
+
const receipt = read?.receipt;
|
|
800
|
+
const keys = ['receiptVersion', 'authorityId', 'authorityHash', 'principalId',
|
|
801
|
+
'principalRecordId', 'principalVersion', 'workspace', 'agentPreset', 'bindingVersion', 'generation'];
|
|
802
|
+
if (read === null || typeof read !== 'object' || read.protocol !== 'assistant-delivery/owner-route-binding/v1'
|
|
803
|
+
|| receipt === null || typeof receipt !== 'object'
|
|
804
|
+
|| typeof read.bindingId !== 'string' || read.bindingId === '' || read.bindingId.length > 256)
|
|
805
|
+
return { kind: 'malformed' };
|
|
806
|
+
// Only a well-formed receipt can drift; a missing or retyped field is a
|
|
807
|
+
// Delivery defect and must not be re-read forever.
|
|
808
|
+
if (!keys.every(key => typeof receipt[key] === typeof owner[key]))
|
|
809
|
+
return { kind: 'malformed' };
|
|
810
|
+
if (!keys.every(key => receipt[key] === owner[key]))
|
|
811
|
+
return { kind: 'drift' };
|
|
812
|
+
return { kind: 'bound', bindingId: read.bindingId };
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* WP21 slice 5: on the same native wake, read back at most `maxPerTick`
|
|
816
|
+
* executed, acceptance-bound items without a verdict through the Host-only
|
|
817
|
+
* `settleAcceptance`. Round-robin over an in-memory cursor so items still
|
|
818
|
+
* pending at the verifier cannot starve later ones. Anything short of a
|
|
819
|
+
* validated receipt writes nothing and is simply read again on a later wake.
|
|
820
|
+
*/
|
|
821
|
+
workItemsAutoSettle(components) {
|
|
822
|
+
const autoSettle = this.workItemsConfig.acceptanceAutoSettle;
|
|
823
|
+
// A missing verifier peer cannot settle anything; skip the scan entirely.
|
|
824
|
+
if (autoSettle === undefined || this.workItemsClosed || this.workItemsVerifier() === undefined)
|
|
825
|
+
return;
|
|
826
|
+
const limit = autoSettle.maxPerTick;
|
|
827
|
+
const batch = [...components.store.listUnsettledAcceptanceItems(this.workItemsAutoSettleCursor, limit)];
|
|
828
|
+
if (batch.length < limit && this.workItemsAutoSettleCursor !== null) {
|
|
829
|
+
const seen = new Set(batch.map(item => item.id));
|
|
830
|
+
for (const item of components.store.listUnsettledAcceptanceItems(null, limit - batch.length))
|
|
831
|
+
if (!seen.has(item.id))
|
|
832
|
+
batch.push(item);
|
|
833
|
+
}
|
|
834
|
+
for (const item of batch) {
|
|
835
|
+
if (this.workItemsClosed)
|
|
836
|
+
break;
|
|
837
|
+
this.workItemsAutoSettleCursor = Object.freeze({ createdAt: item.createdAt, id: item.id });
|
|
838
|
+
try {
|
|
839
|
+
const result = this.settleAcceptance(item.id);
|
|
840
|
+
if (result.outcome !== 'verified' && result.outcome !== 'pending') {
|
|
841
|
+
this.ctx.logger.debug(`assistant-work-items: acceptance auto-settle for ${item.id}: ${result.outcome}`);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
catch (error) {
|
|
845
|
+
if (!this.workItemsWarnMalformedLateBinding(item.id, error)) {
|
|
846
|
+
this.ctx.logger.debug(`assistant-work-items: acceptance auto-settle for ${item.id} not recorded: ${describe(error)}`);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* WP21 slice 8 (`acceptance.holdReport`): the mandate binding, or else the
|
|
853
|
+
* latest late binding while it still belongs to the item's current
|
|
854
|
+
* occurrence. A malformed late row throws `storage`; callers decide whether
|
|
855
|
+
* to hold (fail closed) or treat the item as unbound.
|
|
856
|
+
*/
|
|
857
|
+
workItemsAcceptanceBinding(components, item) {
|
|
858
|
+
const mandate = item.authority?.mandate?.acceptance;
|
|
859
|
+
if (mandate !== undefined)
|
|
860
|
+
return mandate;
|
|
861
|
+
const late = components.store.lateAcceptanceBinding(item.id);
|
|
862
|
+
return late !== undefined && late.occurrenceId === item.occurrenceId ? late.binding : undefined;
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Whether this report must be held instead of dispatched: `holdReport` is
|
|
866
|
+
* on, it is the terminal `ready` self-report of an executed item, the status
|
|
867
|
+
* is notifiable, and the item already has an independent acceptance binding.
|
|
868
|
+
* An item that has not been bound yet (for example a run that reports before
|
|
869
|
+
* the wake that binds it) is reported normally, exactly as today.
|
|
870
|
+
*
|
|
871
|
+
* A verdict recorded for an earlier generation can never settle this report
|
|
872
|
+
* (the store keeps at most one verdict per item and excludes settled items
|
|
873
|
+
* from auto-settle), so holding it would just wait out `holdTimeoutMs` before
|
|
874
|
+
* releasing it unverified. It is dispatched now, exactly like flag-off.
|
|
875
|
+
* An unreadable verdict row fails closed and holds, like a malformed binding.
|
|
876
|
+
*/
|
|
877
|
+
workItemsHoldsReport(components, item, report) {
|
|
878
|
+
if (this.workItemsConfig.acceptanceHoldReport === undefined || this.workItemsClosed
|
|
879
|
+
|| report.status !== 'ready' || item.state !== 'ready' || item.observed)
|
|
880
|
+
return false;
|
|
881
|
+
if (!components.dispatch.shouldNotify(report))
|
|
882
|
+
return false;
|
|
883
|
+
if (this.workItemsAcceptanceBinding(components, item) === undefined)
|
|
884
|
+
return false;
|
|
885
|
+
let verdict;
|
|
886
|
+
try {
|
|
887
|
+
verdict = components.store.acceptanceVerdict(item.id);
|
|
888
|
+
}
|
|
889
|
+
catch {
|
|
890
|
+
return true;
|
|
891
|
+
}
|
|
892
|
+
return verdict === undefined || (verdict.attempt === item.attempt && verdict.occurrenceId === item.occurrenceId);
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Whether this `ready` report is still held. Used to imply the verdict gate
|
|
896
|
+
* for held items even when `acceptance.verdictGate` itself is off: the gate
|
|
897
|
+
* must move the item before the held report can be replaced by the blocked
|
|
898
|
+
* one. A malformed binding row fails closed (treated as bound).
|
|
899
|
+
*/
|
|
900
|
+
workItemsReportIsHeld(components, item) {
|
|
901
|
+
if (this.workItemsConfig.acceptanceHoldReport === undefined || item.state !== 'ready' || item.observed)
|
|
902
|
+
return false;
|
|
903
|
+
const report = components.store.lastReport(item.id);
|
|
904
|
+
if (report === undefined || report.status !== 'ready' || report.deliveryState !== null)
|
|
905
|
+
return false;
|
|
906
|
+
try {
|
|
907
|
+
return this.workItemsAcceptanceBinding(components, item) !== undefined;
|
|
908
|
+
}
|
|
909
|
+
catch {
|
|
910
|
+
return true;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* WP21 slice 8: on the same native wake, release at most
|
|
915
|
+
* `HELD_REPORT_RELEASE_LIMIT` held reports through an in-memory
|
|
916
|
+
* `(recordedAt, itemId)` round-robin cursor. A full page advances the cursor
|
|
917
|
+
* past rows that are still waiting, so they cannot starve a releasable row
|
|
918
|
+
* behind them; a short page resets it and the next wake restarts from the
|
|
919
|
+
* oldest row. Achievement sends the ordinary report; any other settled
|
|
920
|
+
* verdict (or a verdict-gate `blocked` item) sends a blocked report instead;
|
|
921
|
+
* a still-pending item stays held, and one whose `holdTimeoutMs` elapsed is
|
|
922
|
+
* sent marked unverified. A row with no acceptance binding any more was never
|
|
923
|
+
* really held and is recovered as an ordinary report instead of stranded.
|
|
924
|
+
* One unreadable row (a malformed binding or verdict event) is warned about
|
|
925
|
+
* and skipped without aborting the remaining releases; the fail-closed hold
|
|
926
|
+
* itself is unchanged.
|
|
927
|
+
*/
|
|
928
|
+
workItemsReleaseHeldReports(components) {
|
|
929
|
+
const hold = this.workItemsConfig.acceptanceHoldReport;
|
|
930
|
+
if (hold === undefined || this.workItemsClosed)
|
|
931
|
+
return;
|
|
932
|
+
const now = Date.now();
|
|
933
|
+
const page = components.store.listHeldAcceptanceReports(HELD_REPORT_RELEASE_LIMIT, this.workItemsHeldReportCursor ?? undefined);
|
|
934
|
+
// Only a full page proves there may be rows this wake did not examine; a
|
|
935
|
+
// short page ends the rotation so the next wake starts from the oldest.
|
|
936
|
+
if (page.length < HELD_REPORT_RELEASE_LIMIT)
|
|
937
|
+
this.workItemsHeldReportCursor = null;
|
|
938
|
+
else {
|
|
939
|
+
const last = page[page.length - 1];
|
|
940
|
+
this.workItemsHeldReportCursor = Object.freeze({ recordedAt: last.report.recordedAt, itemId: last.item.id });
|
|
941
|
+
}
|
|
942
|
+
for (const { item, report } of page) {
|
|
943
|
+
if (this.workItemsClosed)
|
|
944
|
+
break;
|
|
945
|
+
let release;
|
|
946
|
+
try {
|
|
947
|
+
let bound;
|
|
948
|
+
try {
|
|
949
|
+
bound = this.workItemsAcceptanceBinding(components, item) !== undefined;
|
|
950
|
+
}
|
|
951
|
+
catch (error) {
|
|
952
|
+
if (!this.workItemsWarnMalformedLateBinding(item.id, error)) {
|
|
953
|
+
this.ctx.logger.debug(`assistant-work-items: held report of ${item.id} cannot read its acceptance binding: ${describe(error)}`);
|
|
954
|
+
}
|
|
955
|
+
bound = true;
|
|
956
|
+
}
|
|
957
|
+
// No binding any more means the row was never actually held (the report
|
|
958
|
+
// committed before its pending delivery write, Delivery was missing,
|
|
959
|
+
// `notifyOn` excluded `ready`, or a re-arm lost the binding). Recovery
|
|
960
|
+
// sends the ordinary report instead of leaving it undelivered forever.
|
|
961
|
+
release = bound
|
|
962
|
+
? this.workItemsHeldRelease(components, item, report, now, hold.holdTimeoutMs)
|
|
963
|
+
: Object.freeze({ kind: 'plain' });
|
|
964
|
+
}
|
|
965
|
+
catch (error) {
|
|
966
|
+
// One poison row (a malformed binding or verdict event) must not abort
|
|
967
|
+
// the scan: warn and keep releasing the remaining rows.
|
|
968
|
+
if (!this.workItemsWarnMalformedLateBinding(item.id, error)) {
|
|
969
|
+
this.ctx.logger.warn(`assistant-work-items: held report of ${item.id} cannot be decided: ${describe(error)}`);
|
|
970
|
+
}
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
if (release === undefined)
|
|
974
|
+
continue;
|
|
975
|
+
// A status the operator explicitly excluded from `notifyOn` cannot be
|
|
976
|
+
// sent; record the terminal no-send so every later wake stops rescanning it.
|
|
977
|
+
if (!this.workItemsConfig.notifyOn.includes(release.kind === 'blocked' ? 'blocked' : 'ready')) {
|
|
978
|
+
components.ledger.recordDelivery(item.id, report.revision, 'failed', null);
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
void components.dispatch.notifyHeld(item, report, release).catch(error => {
|
|
982
|
+
this.ctx.logger.warn(`assistant-work-items: held report release failed for ${item.id}: ${describe(error)}`);
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
/** Decide one held report without writing: release it, or wait for a later wake. */
|
|
987
|
+
workItemsHeldRelease(components, item, report, now, timeoutMs) {
|
|
988
|
+
if (item.state === 'blocked') {
|
|
989
|
+
// The store query only returns acceptance-gated blocked rows.
|
|
990
|
+
return { kind: 'blocked', reason: item.stateReason ?? 'acceptance-blocked' };
|
|
991
|
+
}
|
|
992
|
+
const verdict = components.store.acceptanceVerdict(item.id);
|
|
993
|
+
// Only the verdict of this executed generation decides this report; an
|
|
994
|
+
// older generation's verdict cannot be moved by the gate either, so the
|
|
995
|
+
// held report waits for this run's verdict or the timeout.
|
|
996
|
+
if (verdict !== undefined && verdict.attempt === item.attempt && verdict.occurrenceId === item.occurrenceId) {
|
|
997
|
+
return verdict.objectiveStatus === 'achieved'
|
|
998
|
+
? { kind: 'achieved' }
|
|
999
|
+
: { kind: 'blocked', reason: `acceptance-${verdict.objectiveStatus}` };
|
|
1000
|
+
}
|
|
1001
|
+
// A released report is never held again; the owning revision records the send.
|
|
1002
|
+
return now >= report.recordedAt + timeoutMs ? { kind: 'unverified', timeoutMs } : undefined;
|
|
1003
|
+
}
|
|
1004
|
+
get enabled() {
|
|
1005
|
+
return this.workItemsConfig.enabled;
|
|
1006
|
+
}
|
|
1007
|
+
get mode() {
|
|
1008
|
+
return this.workItemsConfig.mode;
|
|
1009
|
+
}
|
|
1010
|
+
get diagnosisEnabled() { return this.workItemsConfig.diagnosis !== undefined; }
|
|
1011
|
+
get ownerContinuationEnabled() { return this.workItemsConfig.ownerContinuation !== undefined; }
|
|
1012
|
+
/** Delivery's ownership fence for the single inbound intake observer. */
|
|
1013
|
+
ownsInboundIntakeObserver = (observer) => !this.workItemsClosed && this.workItemsIntakeObserver !== undefined && observer === this.workItemsIntakeObserver;
|
|
1014
|
+
ownsWorkItemReplyRegistration = (registration) => !this.workItemsClosed && replyConsumers.get(this.workItemsReplyKey)?.owns(registration) === true;
|
|
1015
|
+
issueOwnerWorkItemFeedbackTarget = (input) => feedbackReaders.get(this.workItemsFeedbackKey)?.issue(input);
|
|
1016
|
+
resolveOwnerWorkItemFeedbackTarget = (capability) => feedbackReaders.get(this.workItemsFeedbackKey)?.resolve(capability);
|
|
1017
|
+
withOwnerWorkItemFeedbackTargetsFence = (capabilities, callback) => {
|
|
1018
|
+
const reader = feedbackReaders.get(this.workItemsFeedbackKey);
|
|
1019
|
+
if (this.workItemsClosed || !reader)
|
|
1020
|
+
fail('missing-dependency', 'work-item feedback source fence is unavailable');
|
|
1021
|
+
return reader.withTargetsFence(capabilities, callback);
|
|
1022
|
+
};
|
|
1023
|
+
issueOwnerWorkItemEngineeringTarget = (feedbackCapability) => engineeringReaders.get(this.workItemsFeedbackKey)?.issue(feedbackCapability);
|
|
1024
|
+
resolveOwnerWorkItemEngineeringTarget = (capability) => engineeringReaders.get(this.workItemsFeedbackKey)?.resolve(capability);
|
|
1025
|
+
withOwnerWorkItemEngineeringTargetsFence = (capabilities, callback) => {
|
|
1026
|
+
const reader = engineeringReaders.get(this.workItemsFeedbackKey);
|
|
1027
|
+
if (this.workItemsClosed || !reader)
|
|
1028
|
+
fail('missing-dependency', 'work-item engineering source fence is unavailable');
|
|
1029
|
+
return reader.withTargetsFence(capabilities, callback);
|
|
1030
|
+
};
|
|
1031
|
+
get ownerRecallEnabled() { return this.workItemsConfig.ownerRecall?.enabled === true; }
|
|
1032
|
+
/** Foreground-only historical reads, independent of occurrence/worker authority. */
|
|
1033
|
+
listForOwner = (execution, input) => this.workItemsOwnerReads.list(execution, input);
|
|
1034
|
+
recallForOwner = (execution, input) => this.workItemsOwnerReads.recall(execution, input);
|
|
1035
|
+
continueForOwner = (execution, input) => this.workItemsOwnerContinuations.continue(execution, input);
|
|
1036
|
+
/** Bounded admission check; no work-item recovery, writes, dispatch or model. */
|
|
1037
|
+
preauthorizeTool(execution) {
|
|
1038
|
+
if (execution.name === 'work_item_continue')
|
|
1039
|
+
return this.workItemsOwnerContinuations.preauthorize(execution);
|
|
1040
|
+
if (execution.name === 'work_item_list' || execution.name === 'work_item_recall')
|
|
1041
|
+
return this.workItemsOwnerReads.preauthorize(execution);
|
|
1042
|
+
try {
|
|
1043
|
+
if (execution.signal.aborted)
|
|
1044
|
+
return false;
|
|
1045
|
+
this.workItemsCheckToolExecution(execution, true);
|
|
1046
|
+
return true;
|
|
1047
|
+
}
|
|
1048
|
+
catch {
|
|
1049
|
+
return false;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
/** Approval cannot extend the live occurrence/owner mandate at execution. */
|
|
1053
|
+
assertToolExecution(execution) {
|
|
1054
|
+
this.workItemsCheckToolExecution(execution, false);
|
|
1055
|
+
}
|
|
1056
|
+
workItemsCheckToolExecution(execution, requireModernMandate) {
|
|
1057
|
+
const config = this.workItemsConfig;
|
|
1058
|
+
if (execution.signal?.aborted)
|
|
1059
|
+
fail('invalid-state', 'the work-item tool execution was cancelled');
|
|
1060
|
+
if (this.workItemsClosed || !isActive(config))
|
|
1061
|
+
fail('disabled', 'active work-item intake is required');
|
|
1062
|
+
const components = this.workItemsComponents;
|
|
1063
|
+
if (components === undefined)
|
|
1064
|
+
fail('storage', 'the work-item ledger is unavailable');
|
|
1065
|
+
const occurrenceId = this.workItemsOccurrenceId(execution.agent);
|
|
1066
|
+
const item = components.store.findItemByOccurrence(occurrenceId);
|
|
1067
|
+
if (item === undefined)
|
|
1068
|
+
fail('not-found', 'no work item is bound to this automation occurrence');
|
|
1069
|
+
const scope = config.scope;
|
|
1070
|
+
const header = execution.agent?.session?.header;
|
|
1071
|
+
if (scope === null || header?.cwd !== scope.workspace || header.agentPreset !== scope.preset
|
|
1072
|
+
|| item.scope.workspace !== scope.workspace || item.scope.preset !== scope.preset
|
|
1073
|
+
|| item.scope.principalId !== scope.principalId || item.scope.ownerRouteId !== scope.ownerRouteId
|
|
1074
|
+
|| item.owner.workspace !== scope.workspace || item.owner.agentPreset !== scope.preset) {
|
|
1075
|
+
fail('identity-mismatch', 'work-item tool scope does not match the current worker and admitted owner');
|
|
1076
|
+
}
|
|
1077
|
+
if (scope.expiresAt <= Date.now())
|
|
1078
|
+
fail('owner-route-expired', 'the configured work-item owner scope expired');
|
|
1079
|
+
const owner = this.workItemsAssertOwnerRoute(item);
|
|
1080
|
+
if (owner.principalVersion !== item.owner.principalVersion
|
|
1081
|
+
|| owner.bindingVersion !== item.owner.bindingVersion || owner.generation !== item.owner.generation) {
|
|
1082
|
+
fail('owner-route-invalid', 'owner route generation changed since admission');
|
|
1083
|
+
}
|
|
1084
|
+
if (this.workItemsPolicy() === undefined)
|
|
1085
|
+
fail('missing-dependency', 'assistantPolicy is required for work-item tools');
|
|
1086
|
+
const authority = components.authority.check(item);
|
|
1087
|
+
if (authority.kind === 'revoked')
|
|
1088
|
+
fail('authority-revoked', 'admission mandate no longer permits this work-item tool');
|
|
1089
|
+
if (authority.kind === 'unavailable')
|
|
1090
|
+
fail('authority-unavailable', 'admission mandate could not be revalidated', authority.detail);
|
|
1091
|
+
if (requireModernMandate && authority.kind !== 'allow')
|
|
1092
|
+
fail('authority-unavailable', 'legacy work items require the ordinary approval path');
|
|
1093
|
+
this.workItemsAssertRuntime(components, occurrenceId, execution.name);
|
|
1094
|
+
const active = components.store.activeClaim(item.id);
|
|
1095
|
+
if (active !== undefined && active.runId !== occurrenceId && active.leasedUntil > Date.now())
|
|
1096
|
+
fail('claim-conflict', 'another occurrence owns this work item');
|
|
1097
|
+
const allowed = execution.name === 'work_item_report' ? ['status', 'summary', 'artifacts']
|
|
1098
|
+
: execution.name === 'work_item_read_report' ? ['revision', 'offset', 'max_bytes']
|
|
1099
|
+
: execution.name === 'work_item_read_context' ? ['kind', 'revision', 'offset', 'max_bytes']
|
|
1100
|
+
: execution.name === 'work_item_read_source' ? ['offset', 'max_bytes']
|
|
1101
|
+
: execution.name === 'work_item_claim' || execution.name === 'work_item_status' ? [] : undefined;
|
|
1102
|
+
if (allowed === undefined)
|
|
1103
|
+
fail('invalid-input', 'unknown work-item tool');
|
|
1104
|
+
const args = workItemToolArguments(execution.arguments, allowed);
|
|
1105
|
+
if (execution.name === 'work_item_read_context') {
|
|
1106
|
+
if (!['input', 'parent-brief', 'parent-report', 'parent-outcome'].includes(String(args.kind ?? 'input')))
|
|
1107
|
+
fail('invalid-input', 'invalid continuation context kind');
|
|
1108
|
+
this.readContext(execution.agent, { kind: (args.kind ?? 'input'),
|
|
1109
|
+
...(args.revision === undefined ? {} : { revision: args.revision }),
|
|
1110
|
+
...(args.offset === undefined ? {} : { offset: args.offset }),
|
|
1111
|
+
...(args.max_bytes === undefined ? {} : { maxBytes: args.max_bytes }) });
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
if (execution.name === 'work_item_status')
|
|
1115
|
+
return;
|
|
1116
|
+
if (execution.name === 'work_item_read_source') {
|
|
1117
|
+
if (!config.diagnosis)
|
|
1118
|
+
fail('disabled', 'source recall requires diagnosis mode');
|
|
1119
|
+
const offset = args.offset ?? 0, maximum = args.max_bytes ?? 4096;
|
|
1120
|
+
if (!Number.isSafeInteger(offset) || Number(offset) < 0 || !Number.isSafeInteger(maximum)
|
|
1121
|
+
|| Number(maximum) < 4 || Number(maximum) > 8192)
|
|
1122
|
+
fail('invalid-input', 'source byte window is invalid');
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
if (execution.name === 'work_item_read_report') {
|
|
1126
|
+
this.workItemsReadReportPage(components, item, {
|
|
1127
|
+
revision: args.revision,
|
|
1128
|
+
...(args.offset === undefined ? {} : { offset: args.offset }),
|
|
1129
|
+
...(args.max_bytes === undefined ? {} : { maxBytes: args.max_bytes }),
|
|
1130
|
+
});
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
const now = Date.now();
|
|
1134
|
+
const claim = components.store.findClaimByRun(occurrenceId);
|
|
1135
|
+
if (execution.name === 'work_item_claim') {
|
|
1136
|
+
if (item.observed)
|
|
1137
|
+
fail('invalid-state', 'observed work items cannot be claimed');
|
|
1138
|
+
if (claim?.itemId === item.id && claim.state === 'active' && claim.leasedUntil > now
|
|
1139
|
+
&& item.leaseRunId === occurrenceId && item.leaseUntil !== null && item.leaseUntil > now)
|
|
1140
|
+
return;
|
|
1141
|
+
if (item.attempt >= item.maxAttempts)
|
|
1142
|
+
fail('retry-exhausted', 'work item exhausted its attempt budget');
|
|
1143
|
+
const recoverable = ['claimed', 'working'].includes(item.state) && item.leaseUntil !== null
|
|
1144
|
+
&& item.leaseUntil <= now && (active === undefined || active.leasedUntil <= now);
|
|
1145
|
+
if (!['queued', 'waiting'].includes(item.state) && !recoverable)
|
|
1146
|
+
fail('claim-conflict', 'work item is not claimable');
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
if (typeof args.status !== 'string' || !['progress', 'waiting', 'ready', 'blocked', 'failed'].includes(args.status)
|
|
1150
|
+
|| typeof args.summary !== 'string' || args.summary.trim().length === 0)
|
|
1151
|
+
fail('invalid-input', 'work-item report requires a valid status and non-empty summary');
|
|
1152
|
+
if (args.status === 'waiting' && components.store.getContinuation(item.id))
|
|
1153
|
+
fail('invalid-input', 'continuation attempts cannot schedule waiting retries');
|
|
1154
|
+
const artifacts = args.artifacts ?? [];
|
|
1155
|
+
if (!Array.isArray(artifacts) || artifacts.some(path => typeof path !== 'string'))
|
|
1156
|
+
fail('invalid-input', 'work-item report artifacts must be string paths');
|
|
1157
|
+
const normalizedArtifacts = components.workspace.validateArtifacts(item, artifacts);
|
|
1158
|
+
if (claim === undefined || claim.itemId !== item.id)
|
|
1159
|
+
fail('claim-conflict', 'claim this item before reporting');
|
|
1160
|
+
const reportCount = components.store.reportCount(item.id);
|
|
1161
|
+
if (reportCount > 100)
|
|
1162
|
+
fail('storage', 'work item exceeds the bounded report history');
|
|
1163
|
+
const key = `work-item-report:${workItemReportKey(args.status, args.summary, artifacts)}\0${item.id}\0${String(claim.claimId)}`;
|
|
1164
|
+
const summary = normalizeWorkItemReportSummary(args.summary, config.limits.maxReportBytes);
|
|
1165
|
+
const replay = components.store.reportReplayState({ itemId: item.id, idempotencyKey: key,
|
|
1166
|
+
status: args.status, ...summary, artifactPaths: normalizedArtifacts });
|
|
1167
|
+
if (replay === 'match')
|
|
1168
|
+
return;
|
|
1169
|
+
if (replay === 'conflict')
|
|
1170
|
+
fail('claim-conflict', 'idempotency key already belongs to a different persisted report');
|
|
1171
|
+
if (claim.state !== 'active' || claim.leasedUntil <= now || item.leaseRunId !== occurrenceId
|
|
1172
|
+
|| item.leaseUntil === null || item.leaseUntil <= now)
|
|
1173
|
+
fail('claim-expired', 'report requires the current active claim');
|
|
1174
|
+
if (!['claimed', 'working'].includes(item.state))
|
|
1175
|
+
fail('invalid-state', 'work item is not reportable');
|
|
1176
|
+
if (reportCount >= config.limits.maxReportsPerItem)
|
|
1177
|
+
fail('report-limit', 'work item reached maxReportsPerItem');
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* Admit one external work signal.
|
|
1181
|
+
*
|
|
1182
|
+
* Host-side entry point for a future external source plugin. It performs no
|
|
1183
|
+
* dispatch work beyond the same bounded wake the ledger does on recovery, so
|
|
1184
|
+
* observe mode stays free of external effects.
|
|
1185
|
+
*/
|
|
1186
|
+
admit(input) {
|
|
1187
|
+
const components = this.workItemsAvailable();
|
|
1188
|
+
if (this.workItemsConfig.diagnosis)
|
|
1189
|
+
fail('invalid-input', 'diagnosis worker accepts only its private builtin source');
|
|
1190
|
+
const result = components.admission.admit(input);
|
|
1191
|
+
if (result.created && isActive(this.workItemsConfig)) {
|
|
1192
|
+
try {
|
|
1193
|
+
components.dispatch.wake(result.item);
|
|
1194
|
+
}
|
|
1195
|
+
catch (error) {
|
|
1196
|
+
// The item is durable evidence; a failed wake is recorded on the
|
|
1197
|
+
// dispatch row and retried by the next bounded recovery pass.
|
|
1198
|
+
this.ctx.logger.warn(`assistant-work-items: worker wake failed: ${describe(error)}`);
|
|
1199
|
+
}
|
|
1200
|
+
const dispatch = components.store.getDispatch(result.item.id);
|
|
1201
|
+
if (dispatch !== undefined)
|
|
1202
|
+
return Object.freeze({ ...result, dispatch });
|
|
1203
|
+
}
|
|
1204
|
+
return result;
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Admit one execute decision under a frozen mandate (Host-only, never a tool).
|
|
1208
|
+
*
|
|
1209
|
+
* Same wake semantics as `admit(...)`. A replayed decision id returns its one
|
|
1210
|
+
* existing item; a refusal is a value, not an error, and writes nothing.
|
|
1211
|
+
*/
|
|
1212
|
+
admitDecision(decision) {
|
|
1213
|
+
const components = this.workItemsAvailable();
|
|
1214
|
+
if (this.workItemsConfig.diagnosis)
|
|
1215
|
+
fail('invalid-input', 'diagnosis worker accepts only its private builtin source');
|
|
1216
|
+
const result = components.admission.admitDecision(decision);
|
|
1217
|
+
if (result.outcome !== 'admitted')
|
|
1218
|
+
return result;
|
|
1219
|
+
try {
|
|
1220
|
+
components.dispatch.wake(result.item);
|
|
1221
|
+
}
|
|
1222
|
+
catch (error) {
|
|
1223
|
+
this.ctx.logger.warn(`assistant-work-items: worker wake failed: ${describe(error)}`);
|
|
1224
|
+
}
|
|
1225
|
+
const dispatch = components.store.getDispatch(result.item.id);
|
|
1226
|
+
return dispatch === undefined ? result : Object.freeze({ ...result, dispatch });
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Host-only (WP21): the Policy peer now runs under `policyVersion`.
|
|
1230
|
+
*
|
|
1231
|
+
* Never cancels, parks or wakes work. Each live item whose frozen mandate
|
|
1232
|
+
* basis the current Policy no longer upholds gets one durable notice per
|
|
1233
|
+
* version; replaying the same version (for example after a restart) writes
|
|
1234
|
+
* nothing new. Uses Policy `evaluate`, so no audit row or budget is spent.
|
|
1235
|
+
*/
|
|
1236
|
+
noticePolicyChange(change) {
|
|
1237
|
+
const normalized = normalizePolicyChange(change);
|
|
1238
|
+
const components = this.workItemsAvailable();
|
|
1239
|
+
return noticePolicyChange(components.store, components.authority, normalized);
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* Host-only (WP21 slice 4): read a bound item's verdict back from the
|
|
1243
|
+
* independent verifier. Takes no verdict input; a worker report, the
|
|
1244
|
+
* decision caller or this call's caller can never assert completion. Writes
|
|
1245
|
+
* one append-only verdict event once the verifier's receipt validates
|
|
1246
|
+
* against the frozen contract; anything short of that writes nothing.
|
|
1247
|
+
*/
|
|
1248
|
+
settleAcceptance(itemId) {
|
|
1249
|
+
const components = this.workItemsAvailable();
|
|
1250
|
+
const item = components.store.findItemById(itemId);
|
|
1251
|
+
if (item === undefined)
|
|
1252
|
+
fail('not-found', 'work item does not exist');
|
|
1253
|
+
let binding = item.authority?.mandate?.acceptance;
|
|
1254
|
+
if (binding === undefined) {
|
|
1255
|
+
// WP21 slice 6: the latest late binding, only for the current occurrence;
|
|
1256
|
+
// an earlier generation's unsettled binding never settles re-armed work.
|
|
1257
|
+
const late = components.store.lateAcceptanceBinding(item.id);
|
|
1258
|
+
if (late !== undefined && late.occurrenceId !== item.occurrenceId)
|
|
1259
|
+
fail('invalid-state', 'acceptance binding is for an earlier occurrence');
|
|
1260
|
+
binding = late?.binding;
|
|
1261
|
+
}
|
|
1262
|
+
if (binding === undefined)
|
|
1263
|
+
fail('invalid-state', 'work item has no independent acceptance binding');
|
|
1264
|
+
// The stored mandate must still be intact for this exact item; checked
|
|
1265
|
+
// locally so a pull-based settle never writes Policy audit.
|
|
1266
|
+
if (!storedBasisHolds(item))
|
|
1267
|
+
fail('invalid-state', 'work item authority basis is no longer usable');
|
|
1268
|
+
// Only work that actually ran and reached `ready` can be accepted: never
|
|
1269
|
+
// claimed, parked, blocked, failed or dropped items settle nothing.
|
|
1270
|
+
if (item.attempt < 1 || item.state !== 'ready')
|
|
1271
|
+
fail('invalid-state', `work item is ${item.state}, not executed`);
|
|
1272
|
+
// `holdReport` implies the gate for a held report even when
|
|
1273
|
+
// `acceptance.verdictGate` is off, so a non-achieved verdict can move the
|
|
1274
|
+
// item before the held report is replaced by the blocked one.
|
|
1275
|
+
const gate = this.workItemsConfig.acceptanceVerdictGate === true || this.workItemsReportIsHeld(components, item)
|
|
1276
|
+
? Object.freeze({ attempt: item.attempt, occurrenceId: item.occurrenceId }) : undefined;
|
|
1277
|
+
const result = settleAcceptance(components.store, this.workItemsVerifier(), item, binding, gate);
|
|
1278
|
+
// WP21 slice 7: the verifier's needs-attention writes no verdict, but with
|
|
1279
|
+
// the gate on it still moves this executed generation to `blocked`.
|
|
1280
|
+
if (gate !== undefined && result.outcome === 'needs-attention'
|
|
1281
|
+
&& components.store.blockForAcceptance(item.id, gate, 'acceptance-needs-attention', { contractId: binding.contractId, verifierReason: result.reason })) {
|
|
1282
|
+
return Object.freeze({ ...result, blockedReason: 'acceptance-needs-attention' });
|
|
1283
|
+
}
|
|
1284
|
+
return result;
|
|
1285
|
+
}
|
|
1286
|
+
/** Host-only: the verifier verdict recorded for one item, or null. */
|
|
1287
|
+
acceptanceVerdict(itemId) {
|
|
1288
|
+
const components = this.workItemsAvailable();
|
|
1289
|
+
if (components.store.findItemById(itemId) === undefined)
|
|
1290
|
+
fail('not-found', 'work item does not exist');
|
|
1291
|
+
return components.store.acceptanceVerdict(itemId) ?? null;
|
|
1292
|
+
}
|
|
1293
|
+
/** Host-only: every Policy-change notice recorded for one item. */
|
|
1294
|
+
policyNotices(itemId) {
|
|
1295
|
+
const components = this.workItemsAvailable();
|
|
1296
|
+
if (components.store.findItemById(itemId) === undefined)
|
|
1297
|
+
fail('not-found', 'work item does not exist');
|
|
1298
|
+
return components.store.policyNotices(itemId);
|
|
1299
|
+
}
|
|
1300
|
+
/**
|
|
1301
|
+
* Host-only (WP21): a human follow-up on an item's work thread is owned by
|
|
1302
|
+
* that item while it is live. An ended item takes nothing (`item-ended`).
|
|
1303
|
+
*/
|
|
1304
|
+
ownFollowUp(input) {
|
|
1305
|
+
const normalized = normalizeFollowUp(input);
|
|
1306
|
+
return this.workItemsAvailable().store.ownFollowUp(normalized);
|
|
1307
|
+
}
|
|
1308
|
+
/** Host-only: the live item processed one of its owned follow-ups. */
|
|
1309
|
+
markFollowUpProcessed(itemId, messageId) {
|
|
1310
|
+
return this.workItemsAvailable().store.markFollowUpProcessed(itemId, messageId);
|
|
1311
|
+
}
|
|
1312
|
+
/** Host-only: follow-ups owned by one item. */
|
|
1313
|
+
followUps(itemId) {
|
|
1314
|
+
return this.workItemsAvailable().store.followUps(itemId);
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Host-only: release every unprocessed follow-up of an ended item back to
|
|
1318
|
+
* judgment and return the newly released ones. No message is sent; the
|
|
1319
|
+
* caller decides what judgment does with them.
|
|
1320
|
+
*/
|
|
1321
|
+
releaseFollowUps(limit) {
|
|
1322
|
+
return this.workItemsAvailable().store.releaseEndedFollowUps(limit);
|
|
1323
|
+
}
|
|
1324
|
+
/** Host-only: released follow-ups in release order (restart-safe re-read). */
|
|
1325
|
+
listReleasedFollowUps(options = {}) {
|
|
1326
|
+
return this.workItemsAvailable().store.listReleasedFollowUps(options);
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Record one inbound item's deterministic intake decision (WP20 slice 1).
|
|
1330
|
+
*
|
|
1331
|
+
* Host-only and metadata-only: the input carries normalized booleans and an
|
|
1332
|
+
* optional content digest, never a body. It works in observe mode, writes
|
|
1333
|
+
* only the intake ledger, and never admits, wakes or delivers anything. A
|
|
1334
|
+
* replay of the same `(source, sourceEventId)` returns the original row.
|
|
1335
|
+
*/
|
|
1336
|
+
recordIntake(input, sourceText) {
|
|
1337
|
+
const ledger = this.workItemsConfig.intakeLedger;
|
|
1338
|
+
if (!this.workItemsConfig.enabled || ledger === undefined)
|
|
1339
|
+
fail('disabled', 'the work-item intake ledger is disabled');
|
|
1340
|
+
const candidate = classifyIntake(input, intakeSourcePolicy(ledger.sources, String(input?.source)));
|
|
1341
|
+
// A Host caller gets the same bound and digest check Delivery applies before forwarding a text.
|
|
1342
|
+
if (sourceText !== undefined && !authenticatesIntakeText(sourceText, candidate.contentDigest)) {
|
|
1343
|
+
fail('invalid-input', 'intake source text must be at most 65,536 UTF-16 units and match the content digest');
|
|
1344
|
+
}
|
|
1345
|
+
const result = this.workItemsAvailable().store.recordIntake(candidate);
|
|
1346
|
+
// `sourceText` is the authenticated text the content digest covers; with
|
|
1347
|
+
// evaluation off it is ignored, with it on it is held in memory only.
|
|
1348
|
+
if (sourceText !== undefined)
|
|
1349
|
+
this.workItemsIntakeEvaluation?.observe(result.record, sourceText);
|
|
1350
|
+
return result;
|
|
1351
|
+
}
|
|
1352
|
+
/**
|
|
1353
|
+
* Host-only: register the injectable WP20 inferred-evaluation supplier
|
|
1354
|
+
* (Policy snapshot + model call). Requires `intakeLedger.evaluation`.
|
|
1355
|
+
*/
|
|
1356
|
+
registerIntakeEvaluator(registration) {
|
|
1357
|
+
const evaluation = this.workItemsIntakeEvaluation;
|
|
1358
|
+
if (evaluation === undefined || this.workItemsClosed)
|
|
1359
|
+
fail('disabled', 'intake evaluation is disabled');
|
|
1360
|
+
return evaluation.register(registration);
|
|
1361
|
+
}
|
|
1362
|
+
/** Host-only drive of one bounded evaluation run; the scheduler wake calls the same path. */
|
|
1363
|
+
async runIntakeEvaluation() {
|
|
1364
|
+
if (this.workItemsIntakeEvaluation === undefined || this.workItemsClosed)
|
|
1365
|
+
return [];
|
|
1366
|
+
return this.workItemsIntakeEvaluation.run();
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1369
|
+
* Host-only drive of one bounded admission pass over persisted accepting
|
|
1370
|
+
* evaluations (WP21); the scheduler wake calls it after each evaluation run.
|
|
1371
|
+
* Requires `intakeLedger.evaluation.admit`; never throws.
|
|
1372
|
+
*/
|
|
1373
|
+
runIntakeAdmission() {
|
|
1374
|
+
if (this.workItemsIntakeAdmission === undefined || this.workItemsClosed)
|
|
1375
|
+
return [];
|
|
1376
|
+
return this.workItemsIntakeAdmission.run();
|
|
1377
|
+
}
|
|
1378
|
+
/** Durable admission outcomes (`would-admit`, `admitted`, `replayed`, `refused`) for one intake row. */
|
|
1379
|
+
intakeAdmissions(source, sourceEventId) {
|
|
1380
|
+
if (this.workItemsConfig.intakeLedger === undefined || !this.workItemsConfig.enabled)
|
|
1381
|
+
fail('disabled', 'the work-item intake ledger is disabled');
|
|
1382
|
+
return this.workItemsAvailable().store.listIntakeAdmissions(source, sourceEventId);
|
|
1383
|
+
}
|
|
1384
|
+
/**
|
|
1385
|
+
* Host-only, read-only (WP22): the WP20 intake judgment that admitted one
|
|
1386
|
+
* work item, for Evaluation's intake/attention agreement metric. Undefined
|
|
1387
|
+
* when the item does not exist or was not admitted from intake
|
|
1388
|
+
* (observe-mode `would-admit` never creates an item). Throws `disabled`
|
|
1389
|
+
* when the intake ledger is off, so "cannot tell" is never read as "not
|
|
1390
|
+
* intake". Carries the item's owner scope and no source text.
|
|
1391
|
+
*/
|
|
1392
|
+
inspectIntakeProvenance(itemId) {
|
|
1393
|
+
if (this.workItemsConfig.intakeLedger === undefined || !this.workItemsConfig.enabled)
|
|
1394
|
+
fail('disabled', 'the work-item intake ledger is disabled');
|
|
1395
|
+
if (typeof itemId !== 'string' || itemId === '' || itemId.length > 256)
|
|
1396
|
+
fail('invalid-input', 'itemId is invalid');
|
|
1397
|
+
const store = this.workItemsAvailable().store;
|
|
1398
|
+
const found = store.intakeProvenance(itemId);
|
|
1399
|
+
if (found === undefined)
|
|
1400
|
+
return undefined;
|
|
1401
|
+
const item = store.findItemById(itemId);
|
|
1402
|
+
if (item === undefined)
|
|
1403
|
+
fail('storage', 'intake admission names a missing work item');
|
|
1404
|
+
const { admission, evaluation } = found;
|
|
1405
|
+
return Object.freeze({
|
|
1406
|
+
protocol: 'work-items/intake-provenance/v1',
|
|
1407
|
+
itemId,
|
|
1408
|
+
scope: Object.freeze({ workspace: item.scope.workspace, preset: item.scope.preset }),
|
|
1409
|
+
source: admission.source,
|
|
1410
|
+
sourceEventId: admission.sourceEventId,
|
|
1411
|
+
ruleId: admission.ruleId,
|
|
1412
|
+
policyVersion: admission.policyVersion,
|
|
1413
|
+
mode: admission.mode,
|
|
1414
|
+
level: evaluation.level,
|
|
1415
|
+
wouldAccept: evaluation.wouldAccept,
|
|
1416
|
+
admittedAt: admission.recordedAt,
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
/** Persisted evaluation results for one intake row; replay reads these, never re-evaluates. */
|
|
1420
|
+
intakeEvaluations(source, sourceEventId) {
|
|
1421
|
+
if (this.workItemsConfig.intakeLedger === undefined || !this.workItemsConfig.enabled)
|
|
1422
|
+
fail('disabled', 'the work-item intake ledger is disabled');
|
|
1423
|
+
return this.workItemsAvailable().store.listIntakeEvaluations(source, sourceEventId);
|
|
1424
|
+
}
|
|
1425
|
+
intakeEvaluationHealth() {
|
|
1426
|
+
return this.workItemsIntakeEvaluation?.health();
|
|
1427
|
+
}
|
|
1428
|
+
/** Explicit Host-only reduction of a canonical work signal; never a model tool or a plan mint. */
|
|
1429
|
+
withdraw(input) {
|
|
1430
|
+
const result = this.workItemsAvailable().admission.withdraw(input);
|
|
1431
|
+
if (this.workItemsConfig.diagnosis) {
|
|
1432
|
+
const components = this.workItemsOpen();
|
|
1433
|
+
for (const item of result.items)
|
|
1434
|
+
if (components.store.readDiagnosisPlan(item.id))
|
|
1435
|
+
components.store.clearDiagnosisIntervention(item.id);
|
|
1436
|
+
diagnosisRuntimes.get(this.workItemsDiagnosisKey)?.retire(result.items.map(item => item.id));
|
|
1437
|
+
}
|
|
1438
|
+
continuationRuntimes.get(this.workItemsContinuationKey)?.retire(result.items.map(item => item.id));
|
|
1439
|
+
return result;
|
|
1440
|
+
}
|
|
1441
|
+
/**
|
|
1442
|
+
* Claim the item bound to the calling automation occurrence.
|
|
1443
|
+
*
|
|
1444
|
+
* Identity comes exclusively from the frozen, production automation
|
|
1445
|
+
* execution context installed by `assistant-automations`; a foreground
|
|
1446
|
+
* session, a preview run and another automation all fail closed.
|
|
1447
|
+
*
|
|
1448
|
+
* An armed signal source is swept here (bounded by the persisted
|
|
1449
|
+
* `sweepIntervalMs`) so ordinary use keeps producing work without a timer or
|
|
1450
|
+
* developer orchestration. A sweep failure never fails the claim.
|
|
1451
|
+
*/
|
|
1452
|
+
claim(agent) {
|
|
1453
|
+
const components = this.workItemsRequired();
|
|
1454
|
+
this.workItemsSweepQuietly(components);
|
|
1455
|
+
this.workItemsSettleQuietly(components);
|
|
1456
|
+
const occurrenceId = this.workItemsOccurrenceId(agent);
|
|
1457
|
+
this.workItemsAssertRuntime(components, occurrenceId, 'work_item_claim');
|
|
1458
|
+
if (!this.workItemsConfig.diagnosis)
|
|
1459
|
+
components.ledger.recover();
|
|
1460
|
+
// Lease-expiry and waiting resume are generic recovery work, so ordinary
|
|
1461
|
+
// worker traffic is the lazy sweep that re-dispatches them. This is
|
|
1462
|
+
// best-effort: a recovery failure never blocks the caller's own claim.
|
|
1463
|
+
this.workItemsWakeQuietly(components);
|
|
1464
|
+
const item = components.store.findItemByOccurrence(occurrenceId);
|
|
1465
|
+
if (item === undefined)
|
|
1466
|
+
fail('not-found', 'no work item is bound to this automation occurrence');
|
|
1467
|
+
this.workItemsAssertOwnerRoute(item);
|
|
1468
|
+
this.workItemsRevalidateAuthority(components, item);
|
|
1469
|
+
const runId = occurrenceId;
|
|
1470
|
+
const result = components.ledger.claim({ itemId: item.id, runId, occurrenceId });
|
|
1471
|
+
// A claim can settle a prior window and unblock recovery, so it is a
|
|
1472
|
+
// transaction boundary that may have produced a new exportable revision.
|
|
1473
|
+
this.workItemsDrainEvaluation();
|
|
1474
|
+
// The resume brief is derived only after the claim committed, from the same
|
|
1475
|
+
// durable ledger; a missing row here means storage broke, so fail loudly
|
|
1476
|
+
// instead of handing the model an invented empty context.
|
|
1477
|
+
const resume = components.store.workItemResumeContext(result.item.id);
|
|
1478
|
+
if (resume === undefined)
|
|
1479
|
+
fail('storage', 'work item disappeared before its resume brief was derived');
|
|
1480
|
+
return Object.freeze({
|
|
1481
|
+
itemId: result.item.id,
|
|
1482
|
+
runId: result.claim.runId,
|
|
1483
|
+
state: result.item.state,
|
|
1484
|
+
kind: result.item.kind,
|
|
1485
|
+
externalId: result.item.externalId,
|
|
1486
|
+
title: result.item.title,
|
|
1487
|
+
brief: result.item.brief,
|
|
1488
|
+
briefTruncated: result.item.briefTruncated,
|
|
1489
|
+
acceptanceRef: result.item.acceptanceRef,
|
|
1490
|
+
workDir: result.item.workDir,
|
|
1491
|
+
attempt: result.item.attempt,
|
|
1492
|
+
maxAttempts: result.item.maxAttempts,
|
|
1493
|
+
leaseUntil: result.item.leaseUntil,
|
|
1494
|
+
replayed: result.replayed,
|
|
1495
|
+
resume,
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
/**
|
|
1499
|
+
* Append one report revision under the caller's active claim.
|
|
1500
|
+
*
|
|
1501
|
+
* Reporting is the only worker write path. A terminal status additionally
|
|
1502
|
+
* enqueues one owner notification through `assistant-delivery`; a delivery
|
|
1503
|
+
* failure is recorded against the report instead of erasing report evidence.
|
|
1504
|
+
*/
|
|
1505
|
+
async report(agent, input) {
|
|
1506
|
+
const components = this.workItemsRequired();
|
|
1507
|
+
const occurrenceId = this.workItemsOccurrenceId(agent);
|
|
1508
|
+
this.workItemsAssertRuntime(components, occurrenceId, 'work_item_report');
|
|
1509
|
+
if (!this.workItemsConfig.diagnosis)
|
|
1510
|
+
components.ledger.recover();
|
|
1511
|
+
const item = components.store.findItemByOccurrence(occurrenceId);
|
|
1512
|
+
if (item === undefined)
|
|
1513
|
+
fail('not-found', 'no work item is bound to this automation occurrence');
|
|
1514
|
+
if (input.status === 'waiting' && components.store.getContinuation(item.id))
|
|
1515
|
+
fail('invalid-input', 'continuation attempts cannot schedule waiting retries');
|
|
1516
|
+
const result = components.ledger.report({
|
|
1517
|
+
itemId: item.id,
|
|
1518
|
+
runId: occurrenceId,
|
|
1519
|
+
status: input.status,
|
|
1520
|
+
summary: input.summary,
|
|
1521
|
+
artifactPaths: input.artifactPaths ?? [],
|
|
1522
|
+
idempotencyKey: input.idempotencyKey,
|
|
1523
|
+
});
|
|
1524
|
+
if (!result.replayed) {
|
|
1525
|
+
if (components.store.getContinuation(item.id))
|
|
1526
|
+
continuationRuntimes.get(this.workItemsContinuationKey)?.reported(occurrenceId, result.report.status, result.report.revision);
|
|
1527
|
+
else
|
|
1528
|
+
diagnosisRuntimes.get(this.workItemsDiagnosisKey)?.reported(occurrenceId, result.report.status, result.report.revision);
|
|
1529
|
+
}
|
|
1530
|
+
let notified = false;
|
|
1531
|
+
if (!result.replayed) {
|
|
1532
|
+
try {
|
|
1533
|
+
// A held report is simply not dispatched this pass: the same scheduler
|
|
1534
|
+
// wake that settles the binding releases it in `workItemsReleaseHeldReports`.
|
|
1535
|
+
let held = false;
|
|
1536
|
+
try {
|
|
1537
|
+
held = this.workItemsHoldsReport(components, result.item, result.report);
|
|
1538
|
+
}
|
|
1539
|
+
catch (error) {
|
|
1540
|
+
// A malformed late binding fails closed: keep holding; the timeout
|
|
1541
|
+
// still releases it unverified rather than dropping the report.
|
|
1542
|
+
if (!this.workItemsWarnMalformedLateBinding(item.id, error)) {
|
|
1543
|
+
this.ctx.logger.debug(`assistant-work-items: report hold for ${item.id} cannot read its acceptance binding: ${describe(error)}`);
|
|
1544
|
+
}
|
|
1545
|
+
held = this.workItemsConfig.acceptanceHoldReport !== undefined && result.report.status === 'ready';
|
|
1546
|
+
}
|
|
1547
|
+
notified = held ? false : await components.dispatch.notify(result.item, result.report);
|
|
1548
|
+
}
|
|
1549
|
+
catch (error) {
|
|
1550
|
+
// `notify` already persisted the failed/unknown delivery state. Report
|
|
1551
|
+
// evidence must survive a notification failure, so surface it in the
|
|
1552
|
+
// log and keep the appended revision.
|
|
1553
|
+
this.ctx.logger.warn(`assistant-work-items: owner notification failed: ${describe(error)}`);
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
const stored = components.store.reports(item.id).find(report => report.revision === result.report.revision);
|
|
1557
|
+
// A terminal report appends the settlement outcome synchronously, so this
|
|
1558
|
+
// is the main ordinary-use boundary that feeds the trusted lane.
|
|
1559
|
+
this.workItemsDrainEvaluation();
|
|
1560
|
+
return Object.freeze({
|
|
1561
|
+
itemId: result.item.id,
|
|
1562
|
+
revision: result.report.revision,
|
|
1563
|
+
status: result.report.status,
|
|
1564
|
+
state: result.item.state,
|
|
1565
|
+
truncated: result.report.truncated,
|
|
1566
|
+
artifactPaths: result.report.artifactPaths,
|
|
1567
|
+
delivery: (stored ?? result.report).deliveryState ?? 'pending',
|
|
1568
|
+
replayed: result.replayed,
|
|
1569
|
+
notified,
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
/**
|
|
1573
|
+
* Register one additional automatic source and return an idempotent disposer.
|
|
1574
|
+
*
|
|
1575
|
+
* This is the public extension point for an external signal producer (a
|
|
1576
|
+
* repository watcher, a chat bridge, a scheduler): the producer supplies a
|
|
1577
|
+
* read-only `WorkItemSignalSource`, gets the same bounded sweep, provenance,
|
|
1578
|
+
* dedupe, cooldown and settled-item fence as the built-in adapter, and can be
|
|
1579
|
+
* withdrawn by disposing the registration. Registration fails closed when the
|
|
1580
|
+
* intake is disabled or already disposed, and never admits retroactively.
|
|
1581
|
+
*/
|
|
1582
|
+
registerSignalSource(registration) {
|
|
1583
|
+
if (this.workItemsConfig.diagnosis)
|
|
1584
|
+
fail('invalid-input', 'diagnosis has an exclusive private builtin source; extension registration is unavailable');
|
|
1585
|
+
const components = this.workItemsAvailable();
|
|
1586
|
+
const dispose = components.signals.register(registration);
|
|
1587
|
+
return () => {
|
|
1588
|
+
dispose();
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
/**
|
|
1592
|
+
* Register the only resolver for one followed-thread source.
|
|
1593
|
+
*
|
|
1594
|
+
* Host-only extension point. Registration is one-per-source, validated
|
|
1595
|
+
* synchronously and revoked by the returned disposer. A follow recorded while
|
|
1596
|
+
* a source is registered stores that source's generation, so replacing the
|
|
1597
|
+
* source later degrades the follow to `unresolved` instead of trusting a
|
|
1598
|
+
* stale closure. This adds resolution capability only: it is not authority to
|
|
1599
|
+
* end an item, and a closure never claims an objective was achieved.
|
|
1600
|
+
*/
|
|
1601
|
+
registerThreadSource(registration) {
|
|
1602
|
+
if (!this.workItemsConfig.enabled || this.workItemsClosed)
|
|
1603
|
+
fail('disabled', 'the work-item intake is disabled');
|
|
1604
|
+
if (registration === null || typeof registration !== 'object' || Array.isArray(registration)) {
|
|
1605
|
+
fail('invalid-input', 'thread source registration must be an object');
|
|
1606
|
+
}
|
|
1607
|
+
const allowed = ['source', 'generation', 'resolve', 'validate', 'canonicalize'];
|
|
1608
|
+
for (const key of Object.keys(registration)) {
|
|
1609
|
+
if (!allowed.includes(key))
|
|
1610
|
+
fail('invalid-input', `thread source registration contains unknown field ${key}`);
|
|
1611
|
+
}
|
|
1612
|
+
const source = text(registration.source, 'thread source', 64);
|
|
1613
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/.test(source))
|
|
1614
|
+
fail('invalid-input', 'thread source has an invalid shape');
|
|
1615
|
+
text(registration.generation, 'thread source generation', 200);
|
|
1616
|
+
if (typeof registration.resolve !== 'function')
|
|
1617
|
+
fail('invalid-input', 'thread source must expose a resolve function');
|
|
1618
|
+
if (registration.validate !== undefined && typeof registration.validate !== 'function')
|
|
1619
|
+
fail('invalid-input', 'thread source validate must be a function');
|
|
1620
|
+
if (registration.canonicalize !== undefined && typeof registration.canonicalize !== 'function')
|
|
1621
|
+
fail('invalid-input', 'thread source canonicalize must be a function');
|
|
1622
|
+
if (this.workItemsThreadSources.has(source))
|
|
1623
|
+
fail('invalid-input', `thread source ${source} is already registered`);
|
|
1624
|
+
const installed = registration;
|
|
1625
|
+
this.workItemsThreadSources.set(source, installed);
|
|
1626
|
+
return () => {
|
|
1627
|
+
// Only the disposer of the live registration may revoke it, so a stale
|
|
1628
|
+
// disposer from a replaced source cannot delete the current one.
|
|
1629
|
+
if (this.workItemsThreadSources.get(source) === installed)
|
|
1630
|
+
this.workItemsThreadSources.delete(source);
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
/**
|
|
1634
|
+
* Trusted Evaluation producer generation (fourth trusted producer).
|
|
1635
|
+
*
|
|
1636
|
+
* `assistant-evaluation` reads this before registering a sink; returning a
|
|
1637
|
+
* fresh value on a new service instance is what makes a stale capability
|
|
1638
|
+
* fail closed instead of appending to the wrong host.
|
|
1639
|
+
*/
|
|
1640
|
+
trustedWorkItemProducerGeneration() {
|
|
1641
|
+
return this.workItemsEvaluation.generation;
|
|
1642
|
+
}
|
|
1643
|
+
/**
|
|
1644
|
+
* Install the Evaluation-owned sink this plugin exports settled outcomes to.
|
|
1645
|
+
*
|
|
1646
|
+
* The registration is opaque: Evaluation passes its own capability and this
|
|
1647
|
+
* plugin only validates the protocol, generation and ownership before using
|
|
1648
|
+
* it. Exactly one sink is accepted; the returned disposer withdraws it.
|
|
1649
|
+
*/
|
|
1650
|
+
registerTrustedWorkItemEvaluationSink(registration) {
|
|
1651
|
+
if (this.workItemsClosed)
|
|
1652
|
+
fail('storage', 'the work-item intake is disposed');
|
|
1653
|
+
// The exporter itself runs one bounded catch-up pass the moment the sink is
|
|
1654
|
+
// installed, so the service boundary must not queue a redundant one.
|
|
1655
|
+
const dispose = this.workItemsEvaluation.registerTrustedWorkItemEvaluationSink(registration);
|
|
1656
|
+
return () => {
|
|
1657
|
+
dispose();
|
|
1658
|
+
};
|
|
1659
|
+
}
|
|
1660
|
+
/**
|
|
1661
|
+
* Sweep every armed automatic source once and report what it did.
|
|
1662
|
+
*
|
|
1663
|
+
* Explicit host entry point; `force` ignores the persisted interval. The
|
|
1664
|
+
* disabled default and a missing optional peer both return an empty report,
|
|
1665
|
+
* so a caller never has to special-case activation.
|
|
1666
|
+
*/
|
|
1667
|
+
sweepSignals(force = true) {
|
|
1668
|
+
if (!this.workItemsConfig.enabled || this.workItemsClosed)
|
|
1669
|
+
return Object.freeze({ sources: [] });
|
|
1670
|
+
try {
|
|
1671
|
+
if (this.workItemsConfig.diagnosis)
|
|
1672
|
+
return diagnosisRuntimes.get(this.workItemsDiagnosisKey)?.sweep(force) ?? Object.freeze({ sources: [] });
|
|
1673
|
+
return this.workItemsOpen().signals.sweep(force);
|
|
1674
|
+
}
|
|
1675
|
+
catch (error) {
|
|
1676
|
+
this.ctx.logger.warn(`assistant-work-items: signal sweep unavailable: ${describe(error)}`);
|
|
1677
|
+
return Object.freeze({ sources: [] });
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
/**
|
|
1681
|
+
* Run one bounded settlement pass over the pending outcome windows.
|
|
1682
|
+
*
|
|
1683
|
+
* Host-only by design: there is deliberately no model tool for this, so a
|
|
1684
|
+
* worker can never settle or launder its own outcome. Returns a report instead
|
|
1685
|
+
* of throwing, and a disabled intake returns `skipped: 'disabled'`.
|
|
1686
|
+
*/
|
|
1687
|
+
settleOutcomes(force = true) {
|
|
1688
|
+
if (!this.workItemsConfig.enabled || this.workItemsClosed || !this.workItemsConfig.settlement.enabled) {
|
|
1689
|
+
return Object.freeze({
|
|
1690
|
+
skipped: 'disabled',
|
|
1691
|
+
degraded: null,
|
|
1692
|
+
failed: false,
|
|
1693
|
+
scanned: 0,
|
|
1694
|
+
settled: 0,
|
|
1695
|
+
expired: 0,
|
|
1696
|
+
deferred: 0,
|
|
1697
|
+
closedByThread: 0,
|
|
1698
|
+
followDegraded: null,
|
|
1699
|
+
observedItemIds: Object.freeze([]),
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
try {
|
|
1703
|
+
const result = this.workItemsSettlement(this.workItemsOpen(), force);
|
|
1704
|
+
this.workItemsDrainEvaluation();
|
|
1705
|
+
return result.report;
|
|
1706
|
+
}
|
|
1707
|
+
catch (error) {
|
|
1708
|
+
this.ctx.logger.warn(`assistant-work-items: settlement unavailable: ${describe(error)}`);
|
|
1709
|
+
// Only a genuinely absent dependency is reported as such; any other
|
|
1710
|
+
// failure is an undifferentiated degraded pass, never a silent success.
|
|
1711
|
+
const degraded = error instanceof WorkItemError && error.code === 'missing-dependency'
|
|
1712
|
+
? 'missing-dependency'
|
|
1713
|
+
: null;
|
|
1714
|
+
return Object.freeze({
|
|
1715
|
+
skipped: null,
|
|
1716
|
+
degraded,
|
|
1717
|
+
failed: true,
|
|
1718
|
+
scanned: 0,
|
|
1719
|
+
settled: 0,
|
|
1720
|
+
expired: 0,
|
|
1721
|
+
deferred: 0,
|
|
1722
|
+
closedByThread: 0,
|
|
1723
|
+
followDegraded: null,
|
|
1724
|
+
observedItemIds: Object.freeze([]),
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
/**
|
|
1729
|
+
* Read the latest outcome revision per item, newest first.
|
|
1730
|
+
*
|
|
1731
|
+
* Host-only projection for status/health consumers; the disabled default
|
|
1732
|
+
* returns an empty list rather than opening the ledger.
|
|
1733
|
+
*/
|
|
1734
|
+
outcomes(options = {}) {
|
|
1735
|
+
const limit = options.limit ?? 20;
|
|
1736
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
|
|
1737
|
+
fail('invalid-input', 'limit must be an integer between 1 and 100');
|
|
1738
|
+
if (!this.workItemsConfig.enabled || this.workItemsClosed)
|
|
1739
|
+
return Object.freeze([]);
|
|
1740
|
+
const components = this.workItemsOpen();
|
|
1741
|
+
return components.store.listOutcomes({
|
|
1742
|
+
limit,
|
|
1743
|
+
...(options.evidenceState === undefined ? {} : { evidenceState: options.evidenceState }),
|
|
1744
|
+
});
|
|
1745
|
+
}
|
|
1746
|
+
/** Read-only status of the item bound to the calling occurrence. */
|
|
1747
|
+
status(agent) {
|
|
1748
|
+
const components = this.workItemsRequired();
|
|
1749
|
+
const occurrenceId = this.workItemsOccurrenceId(agent);
|
|
1750
|
+
this.workItemsAssertRuntime(components, occurrenceId, 'work_item_status');
|
|
1751
|
+
if (!this.workItemsConfig.diagnosis)
|
|
1752
|
+
components.ledger.recover();
|
|
1753
|
+
const item = components.store.findItemByOccurrence(occurrenceId);
|
|
1754
|
+
if (item === undefined)
|
|
1755
|
+
fail('not-found', 'no work item is bound to this automation occurrence');
|
|
1756
|
+
return Object.freeze({
|
|
1757
|
+
item,
|
|
1758
|
+
claim: components.store.activeClaim(item.id),
|
|
1759
|
+
reports: components.store.reports(item.id),
|
|
1760
|
+
openItems: components.store.countOpenItems(),
|
|
1761
|
+
outcome: components.store.outcomeView(item.id),
|
|
1762
|
+
});
|
|
1763
|
+
}
|
|
1764
|
+
/**
|
|
1765
|
+
* Recall one report belonging to the caller's exact production occurrence.
|
|
1766
|
+
* This path performs no recovery, parking, settlement or ledger mutation.
|
|
1767
|
+
*/
|
|
1768
|
+
readReport(agent, input) {
|
|
1769
|
+
const components = this.workItemsRequired();
|
|
1770
|
+
const occurrenceId = this.workItemsOccurrenceId(agent);
|
|
1771
|
+
this.workItemsAssertRuntime(components, occurrenceId, 'work_item_read_report');
|
|
1772
|
+
const item = components.store.findItemByOccurrence(occurrenceId);
|
|
1773
|
+
if (item === undefined)
|
|
1774
|
+
fail('not-found', 'no work item is bound to this automation occurrence');
|
|
1775
|
+
const receipt = this.workItemsAssertOwnerRoute(item);
|
|
1776
|
+
if (receipt.principalVersion !== item.owner.principalVersion
|
|
1777
|
+
|| receipt.bindingVersion !== item.owner.bindingVersion
|
|
1778
|
+
|| receipt.generation !== item.owner.generation) {
|
|
1779
|
+
fail('owner-route-invalid', 'owner route generation changed since admission');
|
|
1780
|
+
}
|
|
1781
|
+
const authority = components.authority.check(item);
|
|
1782
|
+
if (authority.kind === 'revoked')
|
|
1783
|
+
fail('authority-revoked', 'admission mandate no longer permits report recall');
|
|
1784
|
+
if (authority.kind === 'unavailable')
|
|
1785
|
+
fail('authority-unavailable', 'admission mandate could not be revalidated', authority.detail);
|
|
1786
|
+
return this.workItemsReadReportPage(components, item, input);
|
|
1787
|
+
}
|
|
1788
|
+
readContext(agent, input) {
|
|
1789
|
+
this.workItemsRequired();
|
|
1790
|
+
const runtime = continuationRuntimes.get(this.workItemsContinuationKey);
|
|
1791
|
+
if (!runtime)
|
|
1792
|
+
fail('missing-dependency', 'continuation context runtime is unavailable');
|
|
1793
|
+
return runtime.readContext(this.workItemsOccurrenceId(agent), input);
|
|
1794
|
+
}
|
|
1795
|
+
workItemsAssertRuntime(components, occurrenceId, name) {
|
|
1796
|
+
const item = components.store.findItemByOccurrence(occurrenceId);
|
|
1797
|
+
if (item && components.store.getContinuation(item.id)) {
|
|
1798
|
+
const runtime = continuationRuntimes.get(this.workItemsContinuationKey);
|
|
1799
|
+
if (!runtime)
|
|
1800
|
+
fail('missing-dependency', 'continuation runtime is unavailable');
|
|
1801
|
+
runtime.assertTool(occurrenceId, name);
|
|
1802
|
+
}
|
|
1803
|
+
else if (this.workItemsConfig.diagnosis) {
|
|
1804
|
+
const runtime = diagnosisRuntimes.get(this.workItemsDiagnosisKey);
|
|
1805
|
+
if (!runtime)
|
|
1806
|
+
fail('missing-dependency', 'diagnosis runtime is unavailable');
|
|
1807
|
+
runtime.assertTool(occurrenceId, name);
|
|
1808
|
+
}
|
|
1809
|
+
else if (name === 'work_item_read_context')
|
|
1810
|
+
fail('invalid-state', 'continuation context requires its own invocation');
|
|
1811
|
+
}
|
|
1812
|
+
readSource(agent, input) {
|
|
1813
|
+
this.workItemsRequired();
|
|
1814
|
+
const runtime = diagnosisRuntimes.get(this.workItemsDiagnosisKey);
|
|
1815
|
+
if (!runtime || !this.workItemsConfig.diagnosis)
|
|
1816
|
+
fail('missing-dependency', 'diagnosis source runtime is unavailable');
|
|
1817
|
+
return runtime.readSource(this.workItemsOccurrenceId(agent), input);
|
|
1818
|
+
}
|
|
1819
|
+
/** Pure SELECT/UTF-8 projection shared by preflight and the read tool. */
|
|
1820
|
+
workItemsReadReportPage(components, item, input) {
|
|
1821
|
+
const offset = input.offset ?? 0;
|
|
1822
|
+
const maxBytes = input.maxBytes ?? 4_096;
|
|
1823
|
+
if (!Number.isSafeInteger(input.revision) || input.revision < 1)
|
|
1824
|
+
fail('invalid-input', 'revision must be a positive safe integer');
|
|
1825
|
+
if (!Number.isSafeInteger(offset) || offset < 0)
|
|
1826
|
+
fail('invalid-input', 'offset must be a non-negative UTF-8 byte offset');
|
|
1827
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 4 || maxBytes > 8_192)
|
|
1828
|
+
fail('invalid-input', 'maxBytes must be an integer between 4 and 8192');
|
|
1829
|
+
const found = components.store.reportByRevision(item.id, input.revision);
|
|
1830
|
+
if (found === undefined)
|
|
1831
|
+
fail('not-found', 'no report with this revision belongs to the current work item');
|
|
1832
|
+
const bytes = Buffer.from(found.report.summary, 'utf8');
|
|
1833
|
+
if (offset > bytes.length || (offset < bytes.length && (bytes[offset] & 0xc0) === 0x80)) {
|
|
1834
|
+
fail('invalid-input', 'offset must be within the stored summary at a UTF-8 character boundary');
|
|
1835
|
+
}
|
|
1836
|
+
let end = Math.min(offset + maxBytes, bytes.length);
|
|
1837
|
+
while (end < bytes.length && (bytes[end] & 0xc0) === 0x80)
|
|
1838
|
+
end -= 1;
|
|
1839
|
+
return Object.freeze({
|
|
1840
|
+
itemId: item.id,
|
|
1841
|
+
revision: found.report.revision,
|
|
1842
|
+
status: found.report.status,
|
|
1843
|
+
summary: bytes.subarray(offset, end).toString('utf8'),
|
|
1844
|
+
offset,
|
|
1845
|
+
nextOffset: end < bytes.length ? end : null,
|
|
1846
|
+
summaryBytes: bytes.length,
|
|
1847
|
+
storedTruncated: found.report.truncated,
|
|
1848
|
+
artifactPaths: found.report.artifactPaths,
|
|
1849
|
+
recordedAt: found.report.recordedAt,
|
|
1850
|
+
previousRevision: found.previousRevision,
|
|
1851
|
+
nextRevision: found.nextRevision,
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
/** Bounded health projection; never creates the ledger when disabled. */
|
|
1855
|
+
health() {
|
|
1856
|
+
const config = this.workItemsConfig;
|
|
1857
|
+
const empty = Object.freeze({
|
|
1858
|
+
enabled: config.enabled,
|
|
1859
|
+
mode: config.mode,
|
|
1860
|
+
openItems: 0,
|
|
1861
|
+
lastAdmitAt: null,
|
|
1862
|
+
lastDispatchAt: null,
|
|
1863
|
+
dispatchUnknown: 0,
|
|
1864
|
+
workerConnected: config.enabled && this.workItemsAutomations() !== undefined,
|
|
1865
|
+
ownerRouteValid: false,
|
|
1866
|
+
follows: Object.freeze({ open: 0, closed: 0, unresolved: 0, degraded: null }),
|
|
1867
|
+
authority: Object.freeze({ legacy: 0, parked: 0, lastDeniedAt: null, lastDeniedReason: null }),
|
|
1868
|
+
settlement: Object.freeze({
|
|
1869
|
+
enabled: config.enabled && config.settlement.enabled,
|
|
1870
|
+
pending: 0,
|
|
1871
|
+
observed: 0,
|
|
1872
|
+
unverified: 0,
|
|
1873
|
+
degraded: null,
|
|
1874
|
+
}),
|
|
1875
|
+
evaluation: Object.freeze({
|
|
1876
|
+
enabled: this.workItemsEvaluationEnabled(),
|
|
1877
|
+
connected: this.workItemsEvaluation.connected,
|
|
1878
|
+
pending: 0,
|
|
1879
|
+
exported: 0,
|
|
1880
|
+
skipped: 0,
|
|
1881
|
+
degraded: this.workItemsEvaluation.degraded,
|
|
1882
|
+
}),
|
|
1883
|
+
});
|
|
1884
|
+
const diagnosis = config.diagnosis || this.workItemsHasDiagnosisHistory ? { diagnosis: diagnosisRuntimes.get(this.workItemsDiagnosisKey)?.health()
|
|
1885
|
+
?? { enabled: true, ready: false, running: 0, lastError: this.workItemsDiagnosisError ?? (this.workItemsHasDiagnosisHistory ? 'diagnosis-disabled-with-history' : 'diagnosis-runtime-unavailable'),
|
|
1886
|
+
interventionRequired: this.workItemsComponents ? persistedDiagnosisIntervention(this.workItemsComponents.store, true) : true } } : {};
|
|
1887
|
+
const continuation = config.ownerContinuation ? { ownerContinuation: continuationRuntimes.get(this.workItemsContinuationKey)?.health()
|
|
1888
|
+
?? { enabled: true, ready: false, running: 0, interventionRequired: true, lastError: 'continuation-activation-unavailable' } } : {};
|
|
1889
|
+
const replies = config.ownerReplyContinuation ? { ownerReplyContinuation: replyConsumers.get(this.workItemsReplyKey)?.health()
|
|
1890
|
+
?? { enabled: true, registered: false, draining: false, lastError: 'reply-consumer-unavailable' } } : {};
|
|
1891
|
+
const feedback = config.ownerReportFeedback ? { ownerReportFeedback: { enabled: true,
|
|
1892
|
+
available: feedbackReaders.get(this.workItemsFeedbackKey)?.available() === true,
|
|
1893
|
+
registered: replyConsumers.get(this.workItemsReplyKey)?.isCurrent() === true } } : {};
|
|
1894
|
+
const schedulerWake = Object.freeze({ subscribed: this.workItemsSchedulerUnsubscribers.size > 0,
|
|
1895
|
+
lastWakeAt: this.workItemsSchedulerLastWakeAt, lastError: this.workItemsSchedulerLastError });
|
|
1896
|
+
if (!config.enabled || this.workItemsClosed)
|
|
1897
|
+
return Object.freeze({ ...empty, schedulerWake, ...diagnosis, ...continuation, ...replies, ...feedback });
|
|
1898
|
+
let components;
|
|
1899
|
+
try {
|
|
1900
|
+
components = this.workItemsOpen();
|
|
1901
|
+
}
|
|
1902
|
+
catch (error) {
|
|
1903
|
+
this.ctx.logger.warn(`assistant-work-items: ledger unavailable: ${describe(error)}`);
|
|
1904
|
+
return Object.freeze({ ...empty, schedulerWake, ...diagnosis, ...continuation, ...replies, ...feedback });
|
|
1905
|
+
}
|
|
1906
|
+
return Object.freeze({
|
|
1907
|
+
schedulerWake,
|
|
1908
|
+
...diagnosis,
|
|
1909
|
+
...continuation,
|
|
1910
|
+
...replies,
|
|
1911
|
+
...feedback,
|
|
1912
|
+
enabled: true,
|
|
1913
|
+
mode: config.mode,
|
|
1914
|
+
openItems: components.store.countOpenItems(),
|
|
1915
|
+
lastAdmitAt: components.store.lastAdmitAt(),
|
|
1916
|
+
lastDispatchAt: components.store.lastDispatchedAt(),
|
|
1917
|
+
dispatchUnknown: components.store.countDispatchUnknown(),
|
|
1918
|
+
workerConnected: workerIsConnected(this.workItemsAutomations()),
|
|
1919
|
+
ownerRouteValid: this.workItemsOwnerRouteValid(),
|
|
1920
|
+
follows: this.workItemsFollowHealth(components.store),
|
|
1921
|
+
authority: this.workItemsAuthorityHealth(components.store),
|
|
1922
|
+
settlement: Object.freeze({
|
|
1923
|
+
enabled: config.settlement.enabled,
|
|
1924
|
+
pending: components.store.countLatestOutcomes('pending'),
|
|
1925
|
+
observed: components.store.countLatestOutcomes('observed'),
|
|
1926
|
+
unverified: components.store.countLatestOutcomes('unverified'),
|
|
1927
|
+
degraded: this.workItemsSettlementDegraded(),
|
|
1928
|
+
}),
|
|
1929
|
+
evaluation: Object.freeze(this.workItemsEvaluationHealth(components)),
|
|
1930
|
+
});
|
|
1931
|
+
}
|
|
1932
|
+
/** Close the ledger exactly once; safe on an intake that never opened. */
|
|
1933
|
+
close() {
|
|
1934
|
+
if (this.workItemsClosed)
|
|
1935
|
+
return;
|
|
1936
|
+
this.workItemsClosed = true;
|
|
1937
|
+
engineeringReaders.get(this.workItemsFeedbackKey)?.close();
|
|
1938
|
+
engineeringReaders.delete(this.workItemsFeedbackKey);
|
|
1939
|
+
feedbackReaders.get(this.workItemsFeedbackKey)?.close();
|
|
1940
|
+
feedbackReaders.delete(this.workItemsFeedbackKey);
|
|
1941
|
+
replyConsumers.get(this.workItemsReplyKey)?.close();
|
|
1942
|
+
replyConsumers.delete(this.workItemsReplyKey);
|
|
1943
|
+
diagnosisRuntimes.get(this.workItemsDiagnosisKey)?.close();
|
|
1944
|
+
diagnosisRuntimes.delete(this.workItemsDiagnosisKey);
|
|
1945
|
+
continuationRuntimes.get(this.workItemsContinuationKey)?.close();
|
|
1946
|
+
continuationRuntimes.delete(this.workItemsContinuationKey);
|
|
1947
|
+
this.workItemsIntakeEvaluation?.close();
|
|
1948
|
+
for (const unregister of this.workItemsSchedulerUnsubscribers) {
|
|
1949
|
+
try {
|
|
1950
|
+
unregister();
|
|
1951
|
+
}
|
|
1952
|
+
catch (error) {
|
|
1953
|
+
this.ctx.logger.warn(`assistant-work-items: scheduler wake removal failed: ${describe(error)}`);
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
this.workItemsEvaluation.dispose();
|
|
1957
|
+
const components = this.workItemsComponents;
|
|
1958
|
+
this.workItemsComponents = undefined;
|
|
1959
|
+
if (components === undefined)
|
|
1960
|
+
return;
|
|
1961
|
+
try {
|
|
1962
|
+
components.store.close();
|
|
1963
|
+
}
|
|
1964
|
+
catch (error) {
|
|
1965
|
+
this.ctx.logger.warn(`assistant-work-items: ledger close failed: ${describe(error)}`);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
/** Export is possible only from an active intake with settlement armed. */
|
|
1969
|
+
workItemsEvaluationEnabled() {
|
|
1970
|
+
return !this.workItemsClosed && isActive(this.workItemsConfig) && this.workItemsConfig.settlement.enabled;
|
|
1971
|
+
}
|
|
1972
|
+
/** One bounded counts read for the export projection. */
|
|
1973
|
+
workItemsEvaluationHealth(components) {
|
|
1974
|
+
const counts = components.store.countEvaluations();
|
|
1975
|
+
const enabled = this.workItemsEvaluationEnabled();
|
|
1976
|
+
return {
|
|
1977
|
+
enabled,
|
|
1978
|
+
connected: this.workItemsEvaluation.connected,
|
|
1979
|
+
pending: counts.pending,
|
|
1980
|
+
exported: counts.exported,
|
|
1981
|
+
skipped: counts.skipped,
|
|
1982
|
+
degraded: this.workItemsEvaluationDegraded(counts.pending),
|
|
1983
|
+
};
|
|
1984
|
+
}
|
|
1985
|
+
/**
|
|
1986
|
+
* Health degradation: a settled revision can never reach the trusted lane.
|
|
1987
|
+
*
|
|
1988
|
+
* A missing optional sink is only a *gap* once something actually awaits
|
|
1989
|
+
* export; a deployment that never installs Evaluation must not look degraded
|
|
1990
|
+
* while it has nothing to export.
|
|
1991
|
+
*/
|
|
1992
|
+
workItemsEvaluationDegraded(pending) {
|
|
1993
|
+
if (!this.workItemsConfig.settlement.enabled)
|
|
1994
|
+
return null;
|
|
1995
|
+
if (this.workItemsConfig.scope === null)
|
|
1996
|
+
return 'owner-route-unavailable';
|
|
1997
|
+
if (!this.workItemsEvaluation.connected)
|
|
1998
|
+
return pending > 0 ? 'sink-unavailable' : null;
|
|
1999
|
+
return this.workItemsEvaluation.degraded;
|
|
2000
|
+
}
|
|
2001
|
+
/** Fire-and-forget bounded drain; export failures must never fail a caller. */
|
|
2002
|
+
workItemsDrainEvaluation() {
|
|
2003
|
+
void this.workItemsEvaluation.drain().catch(error => {
|
|
2004
|
+
this.ctx.logger.warn(`assistant-work-items: Evaluation export failed: ${describe(error)}`);
|
|
2005
|
+
});
|
|
2006
|
+
}
|
|
2007
|
+
workItemsAvailable() {
|
|
2008
|
+
if (!this.workItemsConfig.enabled)
|
|
2009
|
+
fail('disabled', 'the work-item intake is disabled');
|
|
2010
|
+
return this.workItemsOpen();
|
|
2011
|
+
}
|
|
2012
|
+
/** Best-effort bounded sweep; log and continue on any failure. */
|
|
2013
|
+
workItemsSweepQuietly(components) {
|
|
2014
|
+
if (!components.signals.armed())
|
|
2015
|
+
return;
|
|
2016
|
+
try {
|
|
2017
|
+
if (this.workItemsConfig.diagnosis)
|
|
2018
|
+
diagnosisRuntimes.get(this.workItemsDiagnosisKey)?.sweep(false);
|
|
2019
|
+
else
|
|
2020
|
+
components.signals.sweep();
|
|
2021
|
+
}
|
|
2022
|
+
catch (error) {
|
|
2023
|
+
this.ctx.logger.warn(`assistant-work-items: signal sweep failed: ${describe(error)}`);
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
/** Best-effort bounded re-dispatch sweep behind ordinary claim activity. */
|
|
2027
|
+
workItemsWakeQuietly(components) {
|
|
2028
|
+
try {
|
|
2029
|
+
if (this.workItemsConfig.diagnosis)
|
|
2030
|
+
diagnosisRuntimes.get(this.workItemsDiagnosisKey)?.wakePending();
|
|
2031
|
+
else
|
|
2032
|
+
components.dispatch.wakePending();
|
|
2033
|
+
replyConsumers.get(this.workItemsReplyKey)?.drain();
|
|
2034
|
+
continuationRuntimes.get(this.workItemsContinuationKey)?.wakePending();
|
|
2035
|
+
}
|
|
2036
|
+
catch (error) {
|
|
2037
|
+
this.ctx.logger.warn(`assistant-work-items: dispatch recovery failed: ${describe(error)}`);
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
/** Best-effort settlement pass behind ordinary claim activity. */
|
|
2041
|
+
workItemsSettleQuietly(components) {
|
|
2042
|
+
if (!this.workItemsConfig.settlement.enabled)
|
|
2043
|
+
return;
|
|
2044
|
+
try {
|
|
2045
|
+
this.workItemsSettlement(components, false);
|
|
2046
|
+
}
|
|
2047
|
+
catch (error) {
|
|
2048
|
+
this.ctx.logger.warn(`assistant-work-items: settlement failed: ${describe(error)}`);
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
/** Build the bounded settlement pass from config; never admits or wakes. */
|
|
2052
|
+
workItemsSettlement(components, force) {
|
|
2053
|
+
const config = this.workItemsConfig;
|
|
2054
|
+
const scope = config.scope;
|
|
2055
|
+
const result = runSettlement({
|
|
2056
|
+
store: components.store,
|
|
2057
|
+
delivery: this.workItemsDelivery(),
|
|
2058
|
+
scope: scope === null
|
|
2059
|
+
? null
|
|
2060
|
+
: {
|
|
2061
|
+
ownerRouteId: scope.ownerRouteId,
|
|
2062
|
+
principalId: scope.principalId,
|
|
2063
|
+
workspace: scope.workspace,
|
|
2064
|
+
preset: scope.preset,
|
|
2065
|
+
},
|
|
2066
|
+
sweepIntervalMs: config.settlement.sweepIntervalMs,
|
|
2067
|
+
maxSourcesPerSweep: config.settlement.maxSourcesPerSweep,
|
|
2068
|
+
maxPendingPerSweep: config.settlement.maxPendingPerSweep,
|
|
2069
|
+
maxSettlementsPerSweep: config.settlement.maxSettlementsPerSweep,
|
|
2070
|
+
threads: follow => this.workItemsResolveThread(follow),
|
|
2071
|
+
warn: message => this.ctx.logger.warn(message),
|
|
2072
|
+
}, force);
|
|
2073
|
+
this.workItemsFollowDegraded = result.report.followDegraded;
|
|
2074
|
+
return result;
|
|
2075
|
+
}
|
|
2076
|
+
/**
|
|
2077
|
+
* Resolve one open follow against its registered source.
|
|
2078
|
+
*
|
|
2079
|
+
* Synchronous and read-only: no timer, no promise, no write. Every refusal
|
|
2080
|
+
* returns `unresolved` so the item stays parked and health can name the
|
|
2081
|
+
* degradation, rather than a missing source or a stale generation being read
|
|
2082
|
+
* as a closure.
|
|
2083
|
+
*/
|
|
2084
|
+
workItemsResolveThread(follow) {
|
|
2085
|
+
const registration = this.workItemsThreadSources.get(follow.source);
|
|
2086
|
+
if (registration === undefined)
|
|
2087
|
+
return { kind: 'unresolved', reason: 'source-missing' };
|
|
2088
|
+
// A source that regenerated cannot vouch for a follow recorded under its
|
|
2089
|
+
// old generation; the closure may still be true, but it is not observed.
|
|
2090
|
+
if (registration.generation !== follow.sourceGeneration)
|
|
2091
|
+
return { kind: 'unresolved', reason: 'generation-mismatch' };
|
|
2092
|
+
let closure;
|
|
2093
|
+
try {
|
|
2094
|
+
closure = registration.resolve(follow.ref);
|
|
2095
|
+
}
|
|
2096
|
+
catch (error) {
|
|
2097
|
+
this.ctx.logger.warn(`assistant-work-items: thread resolution failed for ${follow.source}: ${describe(error)}`);
|
|
2098
|
+
return { kind: 'unresolved', reason: 'resolver-failed' };
|
|
2099
|
+
}
|
|
2100
|
+
if (closure === undefined)
|
|
2101
|
+
return { kind: 'unresolved', reason: 'resolver-failed' };
|
|
2102
|
+
if (closure === null || typeof closure !== 'object' || Array.isArray(closure)) {
|
|
2103
|
+
return { kind: 'unresolved', reason: 'resolver-failed' };
|
|
2104
|
+
}
|
|
2105
|
+
if (closure.state === 'open')
|
|
2106
|
+
return { kind: 'open' };
|
|
2107
|
+
if (closure.state !== 'closed')
|
|
2108
|
+
return { kind: 'unresolved', reason: 'resolver-failed' };
|
|
2109
|
+
if (!['succeeded', 'failed', 'cancelled', 'unknown'].includes(closure.outcome)
|
|
2110
|
+
|| typeof closure.digest !== 'string' || closure.digest.trim().length === 0
|
|
2111
|
+
|| (closure.observedAt !== undefined && (!Number.isSafeInteger(closure.observedAt) || closure.observedAt <= 0))) {
|
|
2112
|
+
// A malformed closure is never a closure: fail closed toward `waiting`.
|
|
2113
|
+
return { kind: 'unresolved', reason: 'resolver-failed' };
|
|
2114
|
+
}
|
|
2115
|
+
return { kind: 'closed', outcome: closure.outcome, digest: closure.digest };
|
|
2116
|
+
}
|
|
2117
|
+
/** Live follow counts and degradation; never resolves or writes anything. */
|
|
2118
|
+
workItemsFollowHealth(store) {
|
|
2119
|
+
const counts = store.countFollows();
|
|
2120
|
+
let unresolved = 0;
|
|
2121
|
+
let missingSource = false;
|
|
2122
|
+
let mismatched = false;
|
|
2123
|
+
for (const identity of store.openFollowIdentities()) {
|
|
2124
|
+
const registration = this.workItemsThreadSources.get(identity.source);
|
|
2125
|
+
if (registration === undefined) {
|
|
2126
|
+
unresolved += 1;
|
|
2127
|
+
missingSource = true;
|
|
2128
|
+
}
|
|
2129
|
+
else if (registration.generation !== identity.sourceGeneration) {
|
|
2130
|
+
unresolved += 1;
|
|
2131
|
+
mismatched = true;
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
const degraded = missingSource
|
|
2135
|
+
? 'source-missing'
|
|
2136
|
+
: mismatched ? 'generation-mismatch' : this.workItemsFollowDegraded;
|
|
2137
|
+
return Object.freeze({ open: counts.open, closed: counts.closed, unresolved, degraded });
|
|
2138
|
+
}
|
|
2139
|
+
/** Degradation visible in health without running a pass or touching SQLite. */
|
|
2140
|
+
workItemsSettlementDegraded() {
|
|
2141
|
+
if (!this.workItemsConfig.settlement.enabled)
|
|
2142
|
+
return null;
|
|
2143
|
+
if (this.workItemsConfig.scope === null)
|
|
2144
|
+
return 'owner-route-unavailable';
|
|
2145
|
+
return this.workItemsDelivery() === undefined ? 'missing-dependency' : null;
|
|
2146
|
+
}
|
|
2147
|
+
workItemsRequired() {
|
|
2148
|
+
const config = this.workItemsConfig;
|
|
2149
|
+
if (!config.enabled)
|
|
2150
|
+
fail('disabled', 'the work-item intake is disabled');
|
|
2151
|
+
if (!isActive(config))
|
|
2152
|
+
fail('observe-mode', 'the work-item intake is in observe mode; claim and report require mode: active');
|
|
2153
|
+
return this.workItemsOpen();
|
|
2154
|
+
}
|
|
2155
|
+
workItemsOpen() {
|
|
2156
|
+
const existing = this.workItemsComponents;
|
|
2157
|
+
if (existing !== undefined)
|
|
2158
|
+
return existing;
|
|
2159
|
+
if (this.workItemsClosed)
|
|
2160
|
+
fail('storage', 'the work-item intake is disposed');
|
|
2161
|
+
const config = this.workItemsConfig;
|
|
2162
|
+
const workspace = new WorkItemWorkspace(config.workRoot);
|
|
2163
|
+
workspace.prepare();
|
|
2164
|
+
const store = new WorkItemStore(config.databasePath);
|
|
2165
|
+
let diagnosisHistory;
|
|
2166
|
+
try {
|
|
2167
|
+
diagnosisHistory = store.readCanonicalMeta('diagnosis.activation/v1') !== undefined;
|
|
2168
|
+
if (diagnosisHistory && !config.diagnosis && isActive(config))
|
|
2169
|
+
fail('invalid-state', 'a diagnosis ledger cannot be silently activated as a generic worker');
|
|
2170
|
+
}
|
|
2171
|
+
catch (error) {
|
|
2172
|
+
store.close();
|
|
2173
|
+
throw error;
|
|
2174
|
+
}
|
|
2175
|
+
this.workItemsHasDiagnosisHistory = diagnosisHistory;
|
|
2176
|
+
const ledger = new WorkItemLedger({ store, workspace, limits: config.limits, ...(config.diagnosis || diagnosisHistory ? { diagnosis: true } : {}) });
|
|
2177
|
+
// Admission and dispatch both take the same ledger and the same resolved
|
|
2178
|
+
// peers, so one construction order keeps a single writer per durable file.
|
|
2179
|
+
const builders = new WeakMap();
|
|
2180
|
+
const admission = new WorkItemAdmission({
|
|
2181
|
+
config,
|
|
2182
|
+
store,
|
|
2183
|
+
ledger,
|
|
2184
|
+
workspace,
|
|
2185
|
+
policy: this.workItemsPolicy(),
|
|
2186
|
+
delivery: () => this.workItemsDelivery(),
|
|
2187
|
+
verifier: () => this.workItemsVerifier(),
|
|
2188
|
+
diagnosisPlanFor: input => builders.get(input),
|
|
2189
|
+
threadSource: kind => {
|
|
2190
|
+
const registration = this.workItemsThreadSources.get(kind);
|
|
2191
|
+
return registration === undefined
|
|
2192
|
+
? undefined
|
|
2193
|
+
: Object.freeze({
|
|
2194
|
+
source: registration.source,
|
|
2195
|
+
generation: registration.generation,
|
|
2196
|
+
...(registration.validate === undefined ? {} : { validate: (ref) => registration.validate?.(ref) === true }),
|
|
2197
|
+
...(registration.canonicalize === undefined ? {} : { canonicalize: (ref) => registration.canonicalize?.(ref) }),
|
|
2198
|
+
});
|
|
2199
|
+
},
|
|
2200
|
+
});
|
|
2201
|
+
// One lazy policy resolver serves admission and revalidation: a peer that
|
|
2202
|
+
// registers after this plugin becomes visible to both without a restart.
|
|
2203
|
+
const authority = new WorkItemAuthority({ policy: () => this.workItemsPolicy() });
|
|
2204
|
+
const dispatch = new WorkItemDispatch({
|
|
2205
|
+
config,
|
|
2206
|
+
store,
|
|
2207
|
+
ledger,
|
|
2208
|
+
automations: () => this.workItemsAutomations(),
|
|
2209
|
+
delivery: () => this.workItemsDelivery(),
|
|
2210
|
+
validateWake: item => !(config.ownerReportFeedback && config.scope && config.scope.expiresAt <= Date.now())
|
|
2211
|
+
&& (!config.diagnosis || diagnosisRuntimes.get(this.workItemsDiagnosisKey)?.validateWake(item) === true),
|
|
2212
|
+
validateWakeLocal: () => !this.workItemsClosed && (!config.diagnosis || diagnosisRuntimes.get(this.workItemsDiagnosisKey)?.isWakeCurrent() === true),
|
|
2213
|
+
replyReportEligible: (item, report, notification) => replyConsumers.get(this.workItemsReplyKey)?.canEnqueue(item, report, notification) === true,
|
|
2214
|
+
authority,
|
|
2215
|
+
});
|
|
2216
|
+
// Signal sources are optional peers resolved lazily on every sweep, so a
|
|
2217
|
+
// deployment without `assistant-delivery` (or with the source disabled)
|
|
2218
|
+
// stays a no-op instead of failing activation or a claim.
|
|
2219
|
+
const signals = new WorkItemSignalIntake({
|
|
2220
|
+
config,
|
|
2221
|
+
store,
|
|
2222
|
+
admission,
|
|
2223
|
+
delivery: () => this.workItemsDelivery(),
|
|
2224
|
+
dispatch,
|
|
2225
|
+
warn: message => this.ctx.logger.warn(message),
|
|
2226
|
+
});
|
|
2227
|
+
const installed = { workspace, store, ledger, admission, authority, dispatch, signals };
|
|
2228
|
+
this.workItemsComponents = installed;
|
|
2229
|
+
diagnosisAdmissions.set(installed, (input, builder) => {
|
|
2230
|
+
if (!config.diagnosis || !isActive(config) || this.workItemsClosed)
|
|
2231
|
+
fail('disabled', 'private diagnosis admission is unavailable');
|
|
2232
|
+
builders.set(input, builder);
|
|
2233
|
+
try {
|
|
2234
|
+
return admission.admit(input);
|
|
2235
|
+
}
|
|
2236
|
+
finally {
|
|
2237
|
+
builders.delete(input);
|
|
2238
|
+
}
|
|
2239
|
+
});
|
|
2240
|
+
if (config.ownerContinuation) {
|
|
2241
|
+
const { diagnosis: _diagnosis, ...sharedConfig } = config;
|
|
2242
|
+
const continuationConfig = Object.freeze({ ...sharedConfig,
|
|
2243
|
+
workerAutomationId: config.ownerContinuation.automationId,
|
|
2244
|
+
limits: Object.freeze({ ...config.limits, maxAttempts: 1 }) });
|
|
2245
|
+
const continuationBuilders = new WeakMap();
|
|
2246
|
+
const continuationAdmission = new WorkItemAdmission({ config: continuationConfig, store, ledger, workspace,
|
|
2247
|
+
policy: this.workItemsPolicy(), delivery: () => this.workItemsDelivery(),
|
|
2248
|
+
continuationFor: input => continuationBuilders.get(input) });
|
|
2249
|
+
continuationAdmissions.set(installed, (input, continuation) => {
|
|
2250
|
+
if (this.workItemsClosed || !isActive(config))
|
|
2251
|
+
fail('disabled', 'continuation admission is unavailable');
|
|
2252
|
+
continuationBuilders.set(input, continuation);
|
|
2253
|
+
try {
|
|
2254
|
+
return continuationAdmission.admit(input);
|
|
2255
|
+
}
|
|
2256
|
+
finally {
|
|
2257
|
+
continuationBuilders.delete(input);
|
|
2258
|
+
}
|
|
2259
|
+
});
|
|
2260
|
+
continuationDispatches.set(installed, new WorkItemDispatch({ config: continuationConfig, store, ledger,
|
|
2261
|
+
automations: () => this.workItemsAutomations(), delivery: () => this.workItemsDelivery(), authority,
|
|
2262
|
+
validateWake: item => continuationRuntimes.get(this.workItemsContinuationKey)?.validateWake(item) === true,
|
|
2263
|
+
validateWakeLocal: item => !this.workItemsClosed && continuationRuntimes.get(this.workItemsContinuationKey)?.isWakeCurrent(item) === true }));
|
|
2264
|
+
}
|
|
2265
|
+
try {
|
|
2266
|
+
// Bounded startup reconcile: lease reclaim is lazy forever, so a host that
|
|
2267
|
+
// was down catches up on the first touch instead of on a timer.
|
|
2268
|
+
if (!config.diagnosis && !diagnosisHistory)
|
|
2269
|
+
installed.ledger.recover();
|
|
2270
|
+
if (isActive(config) && !config.diagnosis) {
|
|
2271
|
+
try {
|
|
2272
|
+
installed.dispatch.wakePending();
|
|
2273
|
+
}
|
|
2274
|
+
catch (error) {
|
|
2275
|
+
this.ctx.logger.warn(`assistant-work-items: pending wake failed: ${describe(error)}`);
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
catch (error) {
|
|
2280
|
+
this.workItemsComponents = undefined;
|
|
2281
|
+
store.close();
|
|
2282
|
+
throw error;
|
|
2283
|
+
}
|
|
2284
|
+
return installed;
|
|
2285
|
+
}
|
|
2286
|
+
/** Resolve the exact production automation occurrence for this agent. */
|
|
2287
|
+
workItemsOccurrenceId(agent) {
|
|
2288
|
+
if (agent === undefined)
|
|
2289
|
+
fail('identity-mismatch', 'work-item tools require the worker automation run');
|
|
2290
|
+
const execution = agent.ctx.get('assistantAutomationExecution');
|
|
2291
|
+
if (typeof execution !== 'object' || execution === null || Array.isArray(execution)
|
|
2292
|
+
|| !Object.isFrozen(execution)
|
|
2293
|
+
|| Object.keys(execution).sort().join(',') !== 'automationId,mode,occurrenceId') {
|
|
2294
|
+
fail('identity-mismatch', 'work-item tools require the immutable automation execution context');
|
|
2295
|
+
}
|
|
2296
|
+
const installed = execution;
|
|
2297
|
+
if (installed.mode !== 'production'
|
|
2298
|
+
|| typeof installed.automationId !== 'string'
|
|
2299
|
+
|| typeof installed.occurrenceId !== 'string') {
|
|
2300
|
+
fail('identity-mismatch', 'work-item tools are restricted to a production automation run');
|
|
2301
|
+
}
|
|
2302
|
+
const automationId = text(installed.automationId, 'automationId', 500);
|
|
2303
|
+
const occurrenceId = text(installed.occurrenceId, 'occurrenceId', 500);
|
|
2304
|
+
if (automationId !== this.workItemsConfig.workerAutomationId && automationId !== this.workItemsConfig.ownerContinuation?.automationId) {
|
|
2305
|
+
fail('identity-mismatch', 'this automation is not the configured work-item worker');
|
|
2306
|
+
}
|
|
2307
|
+
if (automationId === this.workItemsConfig.ownerContinuation?.automationId) {
|
|
2308
|
+
const item = this.workItemsComponents?.store.findItemByOccurrence(occurrenceId);
|
|
2309
|
+
if (!item || item.workerAutomationId !== automationId || !this.workItemsComponents?.store.getContinuation(item.id)) {
|
|
2310
|
+
fail('identity-mismatch', 'continuation occurrence does not own its persisted item');
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
return occurrenceId;
|
|
2314
|
+
}
|
|
2315
|
+
/** Re-anchor a claim against the live owner route above the stored receipt. */
|
|
2316
|
+
workItemsAssertOwnerRoute(item) {
|
|
2317
|
+
const config = this.workItemsConfig;
|
|
2318
|
+
const delivery = this.workItemsDelivery();
|
|
2319
|
+
if (delivery === undefined)
|
|
2320
|
+
fail('owner-route-unavailable', 'assistantDelivery is required to validate the owner route');
|
|
2321
|
+
const scope = item.scope;
|
|
2322
|
+
if (scope.expiresAt <= Date.now())
|
|
2323
|
+
fail('owner-route-expired', 'the work-item owner scope expired');
|
|
2324
|
+
let receipt;
|
|
2325
|
+
try {
|
|
2326
|
+
receipt = delivery.validateOwnerRoute({
|
|
2327
|
+
authorityId: scope.ownerRouteId,
|
|
2328
|
+
principalId: scope.principalId,
|
|
2329
|
+
workspace: scope.workspace,
|
|
2330
|
+
agentPreset: scope.preset,
|
|
2331
|
+
});
|
|
2332
|
+
}
|
|
2333
|
+
catch (error) {
|
|
2334
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
2335
|
+
const missing = error !== null && typeof error === 'object' && 'code' in error && error.code === 'missing-binding';
|
|
2336
|
+
fail(missing ? 'owner-route-invalid' : 'owner-route-unavailable', 'owner route validation failed', detail);
|
|
2337
|
+
}
|
|
2338
|
+
if (receipt.receiptVersion !== 2
|
|
2339
|
+
|| receipt.workspace !== scope.workspace
|
|
2340
|
+
|| receipt.agentPreset !== scope.preset
|
|
2341
|
+
|| receipt.principalId !== scope.principalId
|
|
2342
|
+
|| receipt.authorityId !== scope.ownerRouteId) {
|
|
2343
|
+
fail('owner-route-invalid', 'owner route receipt does not match the admitted scope');
|
|
2344
|
+
}
|
|
2345
|
+
if (receipt.principalRecordId !== item.owner.principalRecordId) {
|
|
2346
|
+
fail('owner-route-invalid', 'owner principal changed since admission');
|
|
2347
|
+
}
|
|
2348
|
+
if (config.workerAutomationId !== item.workerAutomationId
|
|
2349
|
+
&& (config.ownerContinuation?.automationId !== item.workerAutomationId || !this.workItemsComponents?.store.getContinuation(item.id))) {
|
|
2350
|
+
fail('identity-mismatch', 'item was dispatched to a different worker automation');
|
|
2351
|
+
}
|
|
2352
|
+
return receipt;
|
|
2353
|
+
}
|
|
2354
|
+
/**
|
|
2355
|
+
* Re-validate the stored admission mandate before this claim is allowed.
|
|
2356
|
+
*
|
|
2357
|
+
* Policy remains the only authority: the exact admission request is replayed
|
|
2358
|
+
* against the live policy under the same idempotency key. A denied, failing
|
|
2359
|
+
* or malformed decision parks the item in `waiting` with a typed reason and
|
|
2360
|
+
* fails the claim closed; the item is never claimed and never marked failed.
|
|
2361
|
+
* A later allow resumes it through the same wake path.
|
|
2362
|
+
*/
|
|
2363
|
+
workItemsRevalidateAuthority(components, item) {
|
|
2364
|
+
// A terminal item is not claimable; the ledger refuses it. Revalidating
|
|
2365
|
+
// first would park and re-arm finished work into a new generation.
|
|
2366
|
+
if (TERMINAL_STATES.includes(item.state))
|
|
2367
|
+
return;
|
|
2368
|
+
const check = components.authority.check(item);
|
|
2369
|
+
if (check.kind === 'legacy')
|
|
2370
|
+
return;
|
|
2371
|
+
if (check.kind === 'allow') {
|
|
2372
|
+
components.ledger.restoreAuthority(item);
|
|
2373
|
+
return;
|
|
2374
|
+
}
|
|
2375
|
+
if (check.kind === 'revoked') {
|
|
2376
|
+
components.ledger.parkAuthority(item, 'authority-revoked', { reason: check.reasonCode, phase: 'claim' });
|
|
2377
|
+
components.ledger.rearmAuthority(item);
|
|
2378
|
+
fail('authority-revoked', `admission mandate no longer holds (${check.reasonCode})`);
|
|
2379
|
+
}
|
|
2380
|
+
components.ledger.parkAuthority(item, 'authority-unavailable', { reason: check.detail, phase: 'claim' });
|
|
2381
|
+
components.ledger.rearmAuthority(item);
|
|
2382
|
+
fail('authority-unavailable', 'admission mandate could not be revalidated', check.detail);
|
|
2383
|
+
}
|
|
2384
|
+
workItemsAuthorityHealth(store) {
|
|
2385
|
+
const denied = store.lastAuthorityDenial();
|
|
2386
|
+
return Object.freeze({
|
|
2387
|
+
legacy: store.countAuthorityLegacy(),
|
|
2388
|
+
parked: store.countAuthorityParked(),
|
|
2389
|
+
lastDeniedAt: denied === null ? null : denied.at,
|
|
2390
|
+
lastDeniedReason: denied === null ? null : denied.reason,
|
|
2391
|
+
});
|
|
2392
|
+
}
|
|
2393
|
+
workItemsOwnerRouteValid() {
|
|
2394
|
+
const config = this.workItemsConfig;
|
|
2395
|
+
const scope = config.scope;
|
|
2396
|
+
if (scope === null)
|
|
2397
|
+
return false;
|
|
2398
|
+
const delivery = this.workItemsDelivery();
|
|
2399
|
+
if (delivery === undefined)
|
|
2400
|
+
return false;
|
|
2401
|
+
if (scope.expiresAt <= Date.now())
|
|
2402
|
+
return false;
|
|
2403
|
+
try {
|
|
2404
|
+
const receipt = delivery.validateOwnerRoute({
|
|
2405
|
+
authorityId: scope.ownerRouteId,
|
|
2406
|
+
principalId: scope.principalId,
|
|
2407
|
+
workspace: scope.workspace,
|
|
2408
|
+
agentPreset: scope.preset,
|
|
2409
|
+
});
|
|
2410
|
+
return receipt.receiptVersion === 2 && receipt.workspace === scope.workspace;
|
|
2411
|
+
}
|
|
2412
|
+
catch {
|
|
2413
|
+
return false;
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
workItemsPolicy() {
|
|
2417
|
+
return this.ctx.get('assistantPolicy');
|
|
2418
|
+
}
|
|
2419
|
+
/** Live owner scope for Event Triggers reads; throws when the owner route is unavailable. */
|
|
2420
|
+
workItemsEventFollowOwner(scope) {
|
|
2421
|
+
const delivery = this.workItemsDelivery();
|
|
2422
|
+
if (typeof delivery?.validateOwnerRoute !== 'function')
|
|
2423
|
+
fail('disabled', 'owner route is unavailable for event follows');
|
|
2424
|
+
const receipt = delivery.validateOwnerRoute({ authorityId: scope.ownerRouteId, principalId: scope.principalId, workspace: scope.workspace, agentPreset: scope.preset });
|
|
2425
|
+
if (receipt.principalId !== scope.principalId || receipt.workspace !== scope.workspace || receipt.agentPreset !== scope.preset) {
|
|
2426
|
+
fail('disabled', 'owner route does not match the configured scope');
|
|
2427
|
+
}
|
|
2428
|
+
return Object.freeze({ principalId: receipt.principalId, principalRecordId: receipt.principalRecordId,
|
|
2429
|
+
principalVersion: receipt.principalVersion, workspace: scope.workspace, preset: scope.preset });
|
|
2430
|
+
}
|
|
2431
|
+
workItemsDelivery() {
|
|
2432
|
+
return this.ctx.get('assistantDelivery');
|
|
2433
|
+
}
|
|
2434
|
+
workItemsVerifier() {
|
|
2435
|
+
return this.ctx.get('assistantVerifier');
|
|
2436
|
+
}
|
|
2437
|
+
workItemsAutomations() {
|
|
2438
|
+
return this.ctx.get('assistantAutomations');
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
function workerIsConnected(automations) {
|
|
2442
|
+
return automations !== undefined;
|
|
2443
|
+
}
|
|
2444
|
+
function describe(error) {
|
|
2445
|
+
return error instanceof Error ? error.message : String(error);
|
|
2446
|
+
}
|
|
2447
|
+
//# sourceMappingURL=service.js.map
|