openplanr 1.21.2 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/doctor.js +11 -1
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/operate.d.ts.map +1 -1
- package/dist/cli/commands/operate.js +91 -2
- package/dist/cli/commands/operate.js.map +1 -1
- package/dist/services/operate/advisors.d.ts +63 -0
- package/dist/services/operate/advisors.d.ts.map +1 -1
- package/dist/services/operate/advisors.js +68 -4
- package/dist/services/operate/advisors.js.map +1 -1
- package/dist/services/operate/artifacts.d.ts +9 -3
- package/dist/services/operate/artifacts.d.ts.map +1 -1
- package/dist/services/operate/artifacts.js +126 -5
- package/dist/services/operate/artifacts.js.map +1 -1
- package/dist/services/operate/citation-resolution.d.ts +14 -0
- package/dist/services/operate/citation-resolution.d.ts.map +1 -1
- package/dist/services/operate/citation-resolution.js +9 -0
- package/dist/services/operate/citation-resolution.js.map +1 -1
- package/dist/services/operate/completion.d.ts +80 -0
- package/dist/services/operate/completion.d.ts.map +1 -0
- package/dist/services/operate/completion.js +279 -0
- package/dist/services/operate/completion.js.map +1 -0
- package/dist/services/operate/config.d.ts +7 -0
- package/dist/services/operate/config.d.ts.map +1 -1
- package/dist/services/operate/config.js +7 -0
- package/dist/services/operate/config.js.map +1 -1
- package/dist/services/operate/context-research.d.ts +43 -0
- package/dist/services/operate/context-research.d.ts.map +1 -1
- package/dist/services/operate/context-research.js +219 -1
- package/dist/services/operate/context-research.js.map +1 -1
- package/dist/services/operate/decision-brief.d.ts +90 -1
- package/dist/services/operate/decision-brief.d.ts.map +1 -1
- package/dist/services/operate/decision-brief.js +175 -1
- package/dist/services/operate/decision-brief.js.map +1 -1
- package/dist/services/operate/doctor.d.ts +58 -0
- package/dist/services/operate/doctor.d.ts.map +1 -1
- package/dist/services/operate/doctor.js +395 -21
- package/dist/services/operate/doctor.js.map +1 -1
- package/dist/services/operate/engine.d.ts +75 -2
- package/dist/services/operate/engine.d.ts.map +1 -1
- package/dist/services/operate/engine.js +504 -33
- package/dist/services/operate/engine.js.map +1 -1
- package/dist/services/operate/event-store.d.ts +22 -0
- package/dist/services/operate/event-store.d.ts.map +1 -1
- package/dist/services/operate/event-store.js +35 -0
- package/dist/services/operate/event-store.js.map +1 -1
- package/dist/services/operate/evidence-cache.d.ts.map +1 -1
- package/dist/services/operate/evidence-cache.js +41 -5
- package/dist/services/operate/evidence-cache.js.map +1 -1
- package/dist/services/operate/index.d.ts.map +1 -1
- package/dist/services/operate/index.js +98 -1
- package/dist/services/operate/index.js.map +1 -1
- package/dist/services/operate/interaction/question-engine.d.ts.map +1 -1
- package/dist/services/operate/interaction/question-engine.js +36 -8
- package/dist/services/operate/interaction/question-engine.js.map +1 -1
- package/dist/services/operate/interaction/question-registry.d.ts +8 -0
- package/dist/services/operate/interaction/question-registry.d.ts.map +1 -1
- package/dist/services/operate/interaction/question-registry.js +21 -2
- package/dist/services/operate/interaction/question-registry.js.map +1 -1
- package/dist/services/operate/lifecycle-driver.d.ts +309 -0
- package/dist/services/operate/lifecycle-driver.d.ts.map +1 -0
- package/dist/services/operate/lifecycle-driver.js +434 -0
- package/dist/services/operate/lifecycle-driver.js.map +1 -0
- package/dist/services/operate/maintenance.d.ts +65 -1
- package/dist/services/operate/maintenance.d.ts.map +1 -1
- package/dist/services/operate/maintenance.js +718 -24
- package/dist/services/operate/maintenance.js.map +1 -1
- package/dist/services/operate/mission-dispatch.d.ts +61 -1
- package/dist/services/operate/mission-dispatch.d.ts.map +1 -1
- package/dist/services/operate/mission-dispatch.js +78 -5
- package/dist/services/operate/mission-dispatch.js.map +1 -1
- package/dist/services/operate/profile-migration.d.ts +69 -0
- package/dist/services/operate/profile-migration.d.ts.map +1 -0
- package/dist/services/operate/profile-migration.js +462 -0
- package/dist/services/operate/profile-migration.js.map +1 -0
- package/dist/services/operate/projection-persistence.d.ts.map +1 -1
- package/dist/services/operate/projection-persistence.js +24 -5
- package/dist/services/operate/projection-persistence.js.map +1 -1
- package/dist/services/operate/projection.d.ts.map +1 -1
- package/dist/services/operate/projection.js +9 -0
- package/dist/services/operate/projection.js.map +1 -1
- package/dist/services/operate/protocol.d.ts +1 -0
- package/dist/services/operate/protocol.d.ts.map +1 -1
- package/dist/services/operate/protocol.js.map +1 -1
- package/dist/services/operate/scratch.d.ts +81 -0
- package/dist/services/operate/scratch.d.ts.map +1 -0
- package/dist/services/operate/scratch.js +190 -0
- package/dist/services/operate/scratch.js.map +1 -0
- package/dist/services/operate/types.d.ts +3 -2
- package/dist/services/operate/types.d.ts.map +1 -1
- package/dist/services/operate/types.js.map +1 -1
- package/dist/services/operate/workspace.d.ts +1 -0
- package/dist/services/operate/workspace.d.ts.map +1 -1
- package/dist/services/operate/workspace.js +2 -0
- package/dist/services/operate/workspace.js.map +1 -1
- package/dist/services/runtime-manager-service.d.ts +23 -0
- package/dist/services/runtime-manager-service.d.ts.map +1 -1
- package/dist/services/runtime-manager-service.js +30 -0
- package/dist/services/runtime-manager-service.js.map +1 -1
- package/package.json +2 -2
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
-
import { mkdir, readdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { mkdir, open, readdir, readFile, rename, rm, stat, unlink, writeFile, } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { advisorResponseContractDetails, assertAdvisorOutputMatchesBrief, buildOperatingMandate, createNativeMissionOperatingRoleResult, } from './advisors.js';
|
|
4
|
+
import { advisorResponseContractDetails, assertAdvisorOutputMatchesBrief, buildOperatingMandate, createNativeMissionOperatingRoleResult, DEFAULT_OPERATING_ROLE_RESEARCH_BUDGET_MS, } from './advisors.js';
|
|
5
5
|
import { canonicalDigest, canonicalize, sha256Digest } from './canonical.js';
|
|
6
6
|
import { operatingProjectKey, readOperatingAdapterLeaseDurationMs } from './config.js';
|
|
7
|
+
import { buildOperatingBootstrapMap } from './context-research.js';
|
|
7
8
|
import { gateRecordedProposalCitations } from './engine.js';
|
|
8
|
-
import { OperatingEventStore } from './event-store.js';
|
|
9
|
+
import { OperatingEventStore, toPersistedDataGap } from './event-store.js';
|
|
9
10
|
import { OperatingEvidenceCache } from './evidence-cache.js';
|
|
10
11
|
import { guidedSessionStatus, purgeGuidedSessions } from './interaction/session-service.js';
|
|
11
12
|
import { assertCommittedOperatingView, recoverOperatingTransactions } from './journal.js';
|
|
12
13
|
import { withOperatingLock } from './lock-service.js';
|
|
13
14
|
import { assertOperatingArtifact, loadOperatingProtocol } from './protocol.js';
|
|
14
15
|
import { containsSecret, redactSensitiveText } from './redaction.js';
|
|
15
|
-
import {
|
|
16
|
+
import { cleanOperatingScratch, listAbandonedOperatingScratch } from './scratch.js';
|
|
17
|
+
import { OPERATE_MISSION_PROTOCOL_VERSION, OPERATE_PROTOCOL_VERSION, OPERATE_SCHEMA_VERSION, OperateError, } from './types.js';
|
|
16
18
|
import { readOperatingConfig, refreshOperatingWorkspaceManifest, resolveContainedPath, resolveOperatingPaths, } from './workspace.js';
|
|
17
19
|
function adapterSessionSummary(session) {
|
|
18
20
|
const { roleBriefs: _roleBriefs, roleMandates: _roleMandates, ...summary } = session;
|
|
@@ -20,11 +22,17 @@ function adapterSessionSummary(session) {
|
|
|
20
22
|
}
|
|
21
23
|
async function adapterHandoff(session) {
|
|
22
24
|
const recorded = new Set(session.recordedRoles);
|
|
25
|
+
const notEvaluated = session.notEvaluatedRoles ?? {};
|
|
26
|
+
// T-020: a role is terminal once it has recorded a result OR was governed
|
|
27
|
+
// terminal `not-evaluated`. Only in-flight (pending) roles keep the board in
|
|
28
|
+
// `record-required`; an all-terminal board is `finalize-required`, exactly the
|
|
29
|
+
// T-001 wire contract the pipeline handoff validator enforces.
|
|
30
|
+
const isTerminal = (role) => recorded.has(role) || role in notEvaluated;
|
|
23
31
|
const state = session.state === 'cancelled'
|
|
24
32
|
? 'cancelled'
|
|
25
33
|
: session.state === 'finalized'
|
|
26
34
|
? 'continue-required'
|
|
27
|
-
: session.roles.some((role) => !
|
|
35
|
+
: session.roles.some((role) => !isTerminal(role))
|
|
28
36
|
? 'record-required'
|
|
29
37
|
: 'finalize-required';
|
|
30
38
|
const protocol = await loadOperatingProtocol();
|
|
@@ -40,7 +48,10 @@ async function adapterHandoff(session) {
|
|
|
40
48
|
expiresAt: session.expiresAt,
|
|
41
49
|
roles: session.roles.map((role) => ({
|
|
42
50
|
roleId: role,
|
|
43
|
-
status: recorded.has(role) ? 'recorded' : 'pending',
|
|
51
|
+
status: recorded.has(role) ? 'recorded' : role in notEvaluated ? 'not-evaluated' : 'pending',
|
|
52
|
+
// A governed not-evaluated role carries its reason through the handoff;
|
|
53
|
+
// recorded/pending roles omit the field so existing roles stay byte-identical.
|
|
54
|
+
...(role in notEvaluated ? { statusReason: notEvaluated[role] } : {}),
|
|
44
55
|
inputDigest: session.roleInputDigests[role],
|
|
45
56
|
})),
|
|
46
57
|
});
|
|
@@ -97,6 +108,28 @@ export async function purgeBoardMachineLocalCaches(input) {
|
|
|
97
108
|
const removedIncrementalBaselines = await removeMachineLocalCacheDir(path.join(paths.evidence, 'incremental'));
|
|
98
109
|
return { removedAdvisorSessions, removedIncrementalBaselines };
|
|
99
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* FR7 (T-006): sibling machine-local cleanup for OpenPlanr-owned scratch left
|
|
113
|
+
* behind by a session that never finalized. Deliberately NOT folded into
|
|
114
|
+
* `purgeBoardMachineLocalCaches` — scratch has its own narrower per-cycle
|
|
115
|
+
* lifecycle. Removes ONLY scratch a valid `openplanr-operate-scratch` manifest
|
|
116
|
+
* confirms this project wrote and whose lease window has lapsed; it never touches
|
|
117
|
+
* an unowned file found under the scratch root, nor any other machine-local
|
|
118
|
+
* cache. This is the owned-only removal the doctor's abandoned-scratch diagnostic
|
|
119
|
+
* points at, and the sibling `operate cache purge` invokes.
|
|
120
|
+
*/
|
|
121
|
+
export async function purgeAbandonedOperatingScratch(input) {
|
|
122
|
+
const paths = resolveOperatingPaths(input.projectRoot, { localRoot: input.localRoot });
|
|
123
|
+
const abandoned = await listAbandonedOperatingScratch(paths, input.now ? { now: input.now } : {});
|
|
124
|
+
let removed = 0;
|
|
125
|
+
const cycles = [];
|
|
126
|
+
for (const entry of abandoned) {
|
|
127
|
+
const result = await cleanOperatingScratch(paths, entry.cycleId);
|
|
128
|
+
removed += result.removed;
|
|
129
|
+
cycles.push(entry.cycleId);
|
|
130
|
+
}
|
|
131
|
+
return { removed, cycles: cycles.sort() };
|
|
132
|
+
}
|
|
100
133
|
/**
|
|
101
134
|
* Surface the adapter session lease ergonomically (FR10 / T-008): the absolute
|
|
102
135
|
* `expiresAt` plus the remaining time relative to the resolved clock. Included in
|
|
@@ -129,6 +162,367 @@ async function atomicBytesWrite(target, value) {
|
|
|
129
162
|
await writeFile(temporary, value, { mode: 0o600 });
|
|
130
163
|
await rename(temporary, target);
|
|
131
164
|
}
|
|
165
|
+
// FR1 (SPEC-005 T-002): `harness record` and `harness heartbeat` both mutate ONE
|
|
166
|
+
// shared, lease-bound session file. T-001 widened the handoff so every pending
|
|
167
|
+
// role is authorized to record the instant it returns; that removed the batch
|
|
168
|
+
// barrier but, on the write side, two records returning together would each read
|
|
169
|
+
// the same `recordedRoles` snapshot and — last writer wins — silently drop the
|
|
170
|
+
// other already-recorded role. That trades lost-work-from-a-barrier for a
|
|
171
|
+
// nondeterministic lost-work-from-a-race, which is worse. T-001 states plainly
|
|
172
|
+
// that this handoff "never schedules concurrent session writes", so preventing
|
|
173
|
+
// the race is a write-side property owned here: serialize the session write per
|
|
174
|
+
// cycle and re-read the freshest snapshot inside the critical section before
|
|
175
|
+
// merging. The in-process chain serializes async writers in this process; the
|
|
176
|
+
// O_EXCL lockfile serializes concurrently spawned `planr operate harness record`
|
|
177
|
+
// processes. The per-role result file (`<cycle>.<role>.json`) is the durable
|
|
178
|
+
// cross-process record — re-adopted on prepare/resume — so a stolen stale lock
|
|
179
|
+
// never loses committed work.
|
|
180
|
+
const sessionCommitTails = new Map();
|
|
181
|
+
async function withInProcessSessionLock(key, operation) {
|
|
182
|
+
const previous = sessionCommitTails.get(key) ?? Promise.resolve();
|
|
183
|
+
let openGate;
|
|
184
|
+
const gate = new Promise((resolve) => {
|
|
185
|
+
openGate = resolve;
|
|
186
|
+
});
|
|
187
|
+
// `tail` resolves (never rejects) only once our gate opens, so the next writer
|
|
188
|
+
// in the chain waits for us regardless of whether our operation threw.
|
|
189
|
+
const tail = previous.then(() => gate);
|
|
190
|
+
sessionCommitTails.set(key, tail);
|
|
191
|
+
await previous;
|
|
192
|
+
try {
|
|
193
|
+
return await operation();
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
openGate();
|
|
197
|
+
if (sessionCommitTails.get(key) === tail)
|
|
198
|
+
sessionCommitTails.delete(key);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async function withCrossProcessSessionLock(sessionPath, operation) {
|
|
202
|
+
const lockPath = `${sessionPath}.commit.lock`;
|
|
203
|
+
await mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
204
|
+
const deadline = Date.now() + 10_000;
|
|
205
|
+
let handle;
|
|
206
|
+
for (;;) {
|
|
207
|
+
try {
|
|
208
|
+
handle = await open(lockPath, 'wx', 0o600);
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
if (error.code !== 'EEXIST')
|
|
213
|
+
throw error;
|
|
214
|
+
// Steal only a demonstrably abandoned lock (a crashed record process must
|
|
215
|
+
// never deadlock the session). The per-role result files own committed
|
|
216
|
+
// work, so stealing this mutex risks nothing durable.
|
|
217
|
+
const age = await stat(lockPath)
|
|
218
|
+
.then((info) => Date.now() - info.mtimeMs)
|
|
219
|
+
.catch(() => 0);
|
|
220
|
+
if (age > 30_000) {
|
|
221
|
+
await unlink(lockPath).catch(() => undefined);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (Date.now() > deadline) {
|
|
225
|
+
throw new OperateError('E_OPERATE_STATE_INVALID', 'Timed out serializing the adapter session write.');
|
|
226
|
+
}
|
|
227
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
return await operation();
|
|
232
|
+
}
|
|
233
|
+
finally {
|
|
234
|
+
await handle.close().catch(() => undefined);
|
|
235
|
+
await unlink(lockPath).catch(() => undefined);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Apply a mutation to the shared adapter session under the cross-process lock,
|
|
240
|
+
* re-reading the freshest on-disk snapshot first so a sibling record committed
|
|
241
|
+
* between the caller's snapshot and now is merged forward rather than clobbered
|
|
242
|
+
* (FR1). The lease/idempotency binding and a recordable (`prepared`/`recording`)
|
|
243
|
+
* state are re-asserted inside the critical section, so a concurrent cancel or
|
|
244
|
+
* expiry cannot be silently overwritten. Callers must already hold the
|
|
245
|
+
* in-process session lock for the same `target`.
|
|
246
|
+
*/
|
|
247
|
+
async function commitSessionWrite(target, input) {
|
|
248
|
+
return withCrossProcessSessionLock(target, async () => {
|
|
249
|
+
const current = await readAdapterSession(input.projectRoot, input.cycleId, input.localRoot, input.nowMs);
|
|
250
|
+
assertAdapterBinding(current, input.lease, input.idempotencyKey, input.evidenceDigest);
|
|
251
|
+
if (!['prepared', 'recording'].includes(current.state)) {
|
|
252
|
+
throw new OperateError('E_OPERATE_STATE_INVALID', `Adapter session is ${current.state}; its lease-bound write is no longer valid.`, { recoveryCommand: retryRunCommand(current) });
|
|
253
|
+
}
|
|
254
|
+
const next = input.apply(current);
|
|
255
|
+
await atomicPrivateWrite(target, next);
|
|
256
|
+
return next;
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* FR4/FR5: commit a just-recorded role's canonical `advisory.recorded` event and
|
|
261
|
+
* materialize the readable partial projection immediately, so `planr operate
|
|
262
|
+
* report`/`status` and `.planr/operate/cycles/<id>/` reflect every validated
|
|
263
|
+
* lens BEFORE Chair runs — not only at finalize. The commit is idempotent (a
|
|
264
|
+
* role already committed by a prior record or a finalize reconciliation is
|
|
265
|
+
* skipped; a different digest for the same role is the same isolation error
|
|
266
|
+
* finalize raises) and best-effort under concurrency: if another writer holds the
|
|
267
|
+
* project lock or advanced the event head between replay and append, the role's
|
|
268
|
+
* validated result is already durable in its machine-local file and session
|
|
269
|
+
* entry, and finalize reconciles the canonical event, so the record defers rather
|
|
270
|
+
* than fails.
|
|
271
|
+
*/
|
|
272
|
+
async function materializeRecordedRole(input) {
|
|
273
|
+
const store = new OperatingEventStore(input.projectRoot, { localRoot: input.localRoot });
|
|
274
|
+
try {
|
|
275
|
+
const alreadyCommitted = new Map((await readPersistedOperatingRoleResults(store, input.cycleId)).map((result) => [
|
|
276
|
+
result.roleId,
|
|
277
|
+
result,
|
|
278
|
+
]));
|
|
279
|
+
const prior = alreadyCommitted.get(input.result.roleId);
|
|
280
|
+
if (prior) {
|
|
281
|
+
if (prior.resultDigest !== input.result.resultDigest) {
|
|
282
|
+
throw new OperateError('E_OPERATE_ADVISOR_ISOLATION', `Cycle ${input.cycleId} already committed a different ${input.result.roleId} result.`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
const initial = await store.replay();
|
|
287
|
+
await withOperatingLock(input.projectRoot, {
|
|
288
|
+
projectKey: operatingProjectKey(input.projectRoot),
|
|
289
|
+
expectedEventHead: initial.eventHead,
|
|
290
|
+
currentEventHead: initial.eventHead,
|
|
291
|
+
localRoot: input.localRoot,
|
|
292
|
+
}, async (lock) => {
|
|
293
|
+
// Re-check under the lock: a concurrent writer may have committed this
|
|
294
|
+
// role between our replay and lock acquisition.
|
|
295
|
+
const committed = new Map((await readPersistedOperatingRoleResults(store, input.cycleId)).map((result) => [
|
|
296
|
+
result.roleId,
|
|
297
|
+
result,
|
|
298
|
+
]));
|
|
299
|
+
if (committed.has(input.result.roleId))
|
|
300
|
+
return;
|
|
301
|
+
const record = await store.putRecord('advisor-result', input.result, { correlationId: input.session.idempotencyKey });
|
|
302
|
+
const advisorReportRecord = input.session.protocolVersion === '1.4.0' && input.advisorReport
|
|
303
|
+
? await store.putRecord('advisor-report', input.advisorReport, {
|
|
304
|
+
correlationId: input.session.idempotencyKey,
|
|
305
|
+
})
|
|
306
|
+
: null;
|
|
307
|
+
const runtimeBinding = (await adapterHandoff(input.session)).binding;
|
|
308
|
+
const event = await store.append({
|
|
309
|
+
type: 'advisory.recorded',
|
|
310
|
+
cycleId: input.cycleId,
|
|
311
|
+
entityId: `${input.cycleId}-${input.result.roleId}`,
|
|
312
|
+
correlationId: input.session.idempotencyKey,
|
|
313
|
+
evidenceRefs: input.result.proposals.flatMap((proposal) => proposal.evidenceRefs),
|
|
314
|
+
payload: {
|
|
315
|
+
recordDigest: record.digest,
|
|
316
|
+
...(advisorReportRecord ? { advisorReportDigest: advisorReportRecord.digest } : {}),
|
|
317
|
+
roleId: input.result.roleId,
|
|
318
|
+
runtimeBinding: {
|
|
319
|
+
runtime: runtimeBinding.runtime,
|
|
320
|
+
runtimeBinding: runtimeBinding.runtimeBinding,
|
|
321
|
+
crossRuntimeFallback: runtimeBinding.crossRuntimeFallback,
|
|
322
|
+
executionMode: runtimeBinding.executionMode,
|
|
323
|
+
assurance: runtimeBinding.assurance,
|
|
324
|
+
toolIsolation: runtimeBinding.toolIsolation,
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
...(input.session.protocolVersion === '1.4.0'
|
|
328
|
+
? { protocolVersion: '1.4.0' }
|
|
329
|
+
: {}),
|
|
330
|
+
expectedHead: initial.eventHead.hash,
|
|
331
|
+
actor: { kind: 'runtime', id: 'operate-adapter' },
|
|
332
|
+
});
|
|
333
|
+
await lock.advanceEventHead(initial.eventHead, {
|
|
334
|
+
sequence: event.sequence,
|
|
335
|
+
hash: event.eventHash,
|
|
336
|
+
});
|
|
337
|
+
await store.writeCheckpoint(await store.state());
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
// Refresh the readable projection from the freshest committed state so the
|
|
341
|
+
// recorded lens' Markdown lands under `.planr/operate/cycles/<id>/` at once.
|
|
342
|
+
const { persistOperatingProjections } = await import('./projection-persistence.js');
|
|
343
|
+
await persistOperatingProjections({
|
|
344
|
+
projectRoot: input.projectRoot,
|
|
345
|
+
localRoot: input.localRoot,
|
|
346
|
+
state: await store.state(),
|
|
347
|
+
revalidateEventHead: async () => (await store.replay()).eventHead,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
if (error instanceof OperateError &&
|
|
352
|
+
['E_OPERATE_CYCLE_ACTIVE', 'E_OPERATE_ROUTE_DRIFT'].includes(error.code)) {
|
|
353
|
+
// A concurrent writer holds the project lock or advanced the head between
|
|
354
|
+
// our replay and commit. The validated result is already durable in its
|
|
355
|
+
// machine-local file and session entry; finalize reconciles the canonical
|
|
356
|
+
// event idempotently. Defer rather than fail the record.
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
function maximumOperatingOrdinal(records, prefix) {
|
|
363
|
+
return records.reduce((maximum, record) => {
|
|
364
|
+
const match = String(record.id ?? '').match(new RegExp(`^${prefix}-(\\d+)$`));
|
|
365
|
+
return Math.max(maximum, match ? Number(match[1]) : 0);
|
|
366
|
+
}, 0);
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* SPEC-005 T-019 — persist the citation gate's governed gaps on the agent-native
|
|
370
|
+
* adapter-lifecycle `record` path, exactly as the inline consolidation loop
|
|
371
|
+
* (`engine.ts`) persists its dispatch gaps.
|
|
372
|
+
*
|
|
373
|
+
* A lens whose every proposal is citation-rejected records a schema-legal `quiet`
|
|
374
|
+
* result; the `unresolvable-citation` / `missing-evidence` gaps that record WHY it
|
|
375
|
+
* grounded nothing were previously only echoed transiently in the record response
|
|
376
|
+
* (`citationGaps`) and never reached the event store. So the Chair board —
|
|
377
|
+
* assembled by a later `run` continuation, after this role is already recorded and
|
|
378
|
+
* is never re-dispatched — saw no citation signal in committed state and rendered
|
|
379
|
+
* the lens a false-clean `recorded-quiet` (reason `null`, gapId `null`): the exact
|
|
380
|
+
* dishonest surface SPEC-005 exists to eliminate, on the path real runtimes use.
|
|
381
|
+
* Persisting the governed gap here makes it durable, readable back from the event
|
|
382
|
+
* store, and reconstructible into the `citation-rejected` classification at
|
|
383
|
+
* consolidation (see `engine.ts`).
|
|
384
|
+
*
|
|
385
|
+
* This reuses T-017's landed `toPersistedDataGap` v1.2 projection verbatim — the
|
|
386
|
+
* SAME projection the inline path relies on (category/citations dropped, canonical
|
|
387
|
+
* `GAP-NNN` id) — so the gap validates against the append log / `operating-record`
|
|
388
|
+
* schemas instead of throwing `E_OPERATE_STATE_INVALID` (`$ matched 0/11
|
|
389
|
+
* branches`). It is NOT a second, forked persistence path; it carries T-017's
|
|
390
|
+
* signal across the record→consolidation boundary the inline path never crosses.
|
|
391
|
+
*
|
|
392
|
+
* Idempotent: the citation gate names every gap it opens for a role in that gap's
|
|
393
|
+
* `affectedRoles`, so a role already gap-named in committed state is a no-op —
|
|
394
|
+
* a resume, retry, or idempotent re-record never mints a duplicate under a fresh
|
|
395
|
+
* `GAP-NNN` id.
|
|
396
|
+
*/
|
|
397
|
+
async function persistRecordedRoleGaps(input) {
|
|
398
|
+
if (input.gaps.length === 0)
|
|
399
|
+
return;
|
|
400
|
+
const store = new OperatingEventStore(input.projectRoot, { localRoot: input.localRoot });
|
|
401
|
+
const namesRole = (gap) => (gap.cycleId === undefined || gap.cycleId === input.cycleId) &&
|
|
402
|
+
Array.isArray(gap.affectedRoles) &&
|
|
403
|
+
gap.affectedRoles.includes(input.roleId);
|
|
404
|
+
if ((await store.state()).dataGaps.some(namesRole))
|
|
405
|
+
return;
|
|
406
|
+
try {
|
|
407
|
+
const initial = await store.replay();
|
|
408
|
+
await withOperatingLock(input.projectRoot, {
|
|
409
|
+
projectKey: operatingProjectKey(input.projectRoot),
|
|
410
|
+
expectedEventHead: initial.eventHead,
|
|
411
|
+
currentEventHead: initial.eventHead,
|
|
412
|
+
localRoot: input.localRoot,
|
|
413
|
+
}, async (lock) => {
|
|
414
|
+
// Re-check under the lock: a concurrent writer may have persisted this
|
|
415
|
+
// role's gaps between our replay and lock acquisition.
|
|
416
|
+
const current = await store.state();
|
|
417
|
+
if (current.dataGaps.some(namesRole))
|
|
418
|
+
return;
|
|
419
|
+
let head = initial.eventHead;
|
|
420
|
+
let gapOrdinal = maximumOperatingOrdinal(current.dataGaps, 'GAP');
|
|
421
|
+
for (const rawGap of input.gaps) {
|
|
422
|
+
const gap = toPersistedDataGap(rawGap, () => `GAP-${String(++gapOrdinal).padStart(3, '0')}`);
|
|
423
|
+
await assertOperatingArtifact('operating-data-gap', gap);
|
|
424
|
+
await store.putRecord('data-gap', structuredClone(gap), { correlationId: input.correlationId });
|
|
425
|
+
const event = await store.append({
|
|
426
|
+
type: 'gap.open',
|
|
427
|
+
cycleId: input.cycleId,
|
|
428
|
+
entityId: gap.id,
|
|
429
|
+
correlationId: input.correlationId,
|
|
430
|
+
evidenceRefs: gap.evidenceRefs,
|
|
431
|
+
payload: { record: gap },
|
|
432
|
+
expectedHead: head.hash,
|
|
433
|
+
actor: { kind: 'runtime', id: 'operate-adapter' },
|
|
434
|
+
});
|
|
435
|
+
const next = { sequence: event.sequence, hash: event.eventHash };
|
|
436
|
+
await lock.advanceEventHead(head, next);
|
|
437
|
+
head = next;
|
|
438
|
+
}
|
|
439
|
+
await store.writeCheckpoint(await store.state());
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
if (error instanceof OperateError &&
|
|
444
|
+
['E_OPERATE_CYCLE_ACTIVE', 'E_OPERATE_ROUTE_DRIFT'].includes(error.code)) {
|
|
445
|
+
// A concurrent writer holds the project lock or advanced the head between our
|
|
446
|
+
// replay and commit. The governed gap is idempotent and content-addressed;
|
|
447
|
+
// the next record/finalize reconciles it. Defer rather than fail the record.
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
throw error;
|
|
451
|
+
}
|
|
452
|
+
const { persistOperatingProjections } = await import('./projection-persistence.js');
|
|
453
|
+
await persistOperatingProjections({
|
|
454
|
+
projectRoot: input.projectRoot,
|
|
455
|
+
localRoot: input.localRoot,
|
|
456
|
+
state: await store.state(),
|
|
457
|
+
revalidateEventHead: async () => (await store.replay()).eventHead,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* SPEC-005 T-020 (FR13): the governed gap recording a lens terminal
|
|
462
|
+
* `not_evaluated` because it was dispatched but never returned a result — a true
|
|
463
|
+
* stall on the agent-native adapter-lifecycle path, distinct from T-019's
|
|
464
|
+
* citation-rejected case (a lens that DID return a result grounding zero
|
|
465
|
+
* evidence). Shaped `missing-evidence` in memory so `toPersistedDataGap` mints its
|
|
466
|
+
* canonical `GAP-NNN` id and strips `category` for the v1.2 committed projection —
|
|
467
|
+
* the same projection T-017/T-019 rely on. This gap, in committed state, IS the
|
|
468
|
+
* durable terminal signal: a role with NO committed result but named by such a
|
|
469
|
+
* cycle gap is reconstructed `not_evaluated` at consolidation (`engine.ts`), keyed
|
|
470
|
+
* on that committed-state fact, never the gap prose.
|
|
471
|
+
*/
|
|
472
|
+
function buildTerminalNotEvaluatedGap(input) {
|
|
473
|
+
return {
|
|
474
|
+
kind: 'operating-data-gap',
|
|
475
|
+
schemaVersion: OPERATE_SCHEMA_VERSION,
|
|
476
|
+
protocolVersion: OPERATE_MISSION_PROTOCOL_VERSION,
|
|
477
|
+
id: `GAP-${canonicalDigest({
|
|
478
|
+
roleId: input.roleId,
|
|
479
|
+
cycleId: input.cycleId,
|
|
480
|
+
reason: 'stalled-not-evaluated',
|
|
481
|
+
}).slice('sha256:'.length)}`,
|
|
482
|
+
cycleId: input.cycleId,
|
|
483
|
+
category: 'missing-evidence',
|
|
484
|
+
question: `Why did ${input.roleId} not evaluate this cycle? It was dispatched but never recorded a result.`,
|
|
485
|
+
reason: input.reason,
|
|
486
|
+
unblocks: [],
|
|
487
|
+
affectedRoles: [input.roleId],
|
|
488
|
+
status: 'open',
|
|
489
|
+
owner: input.owner && input.owner.length > 0 ? input.owner : 'chair',
|
|
490
|
+
evidenceRefs: [],
|
|
491
|
+
createdAt: input.now,
|
|
492
|
+
updatedAt: input.now,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* SPEC-005 T-020 — terminally govern one dispatched-but-unrecorded lens
|
|
497
|
+
* `not_evaluated` with a reason, in committed state. Shared by the runtime
|
|
498
|
+
* `harness abandon` action (lease-bound) and the operator escape
|
|
499
|
+
* (`reapStalledOperatingRoles`, keyed on a lapsed lease). It persists the governed
|
|
500
|
+
* gap through the SAME durable path T-019's citation gaps use
|
|
501
|
+
* (`persistRecordedRoleGaps` → `gap.open` event), so a terminal transition is
|
|
502
|
+
* always a governed event carrying its reason. Idempotent per role: a role already
|
|
503
|
+
* named by a committed gap is a no-op. Never fabricates a result — the lens
|
|
504
|
+
* contributes zero grounded proposals by construction (it recorded none).
|
|
505
|
+
*/
|
|
506
|
+
async function governTerminalNotEvaluated(input) {
|
|
507
|
+
const config = await readOperatingConfig(input.projectRoot, {
|
|
508
|
+
localRoot: input.localRoot,
|
|
509
|
+
}).catch(() => null);
|
|
510
|
+
const gap = buildTerminalNotEvaluatedGap({
|
|
511
|
+
cycleId: input.cycleId,
|
|
512
|
+
roleId: input.roleId,
|
|
513
|
+
reason: input.reason,
|
|
514
|
+
owner: config?.decisionOwner ?? 'chair',
|
|
515
|
+
now: new Date().toISOString(),
|
|
516
|
+
});
|
|
517
|
+
await persistRecordedRoleGaps({
|
|
518
|
+
projectRoot: input.projectRoot,
|
|
519
|
+
localRoot: input.localRoot,
|
|
520
|
+
cycleId: input.cycleId,
|
|
521
|
+
roleId: input.roleId,
|
|
522
|
+
gaps: [gap],
|
|
523
|
+
correlationId: input.correlationId,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
132
526
|
async function regularFiles(root) {
|
|
133
527
|
const files = [];
|
|
134
528
|
async function visit(directory) {
|
|
@@ -193,15 +587,23 @@ export async function operatingCacheAction(input) {
|
|
|
193
587
|
projectRoot: input.projectRoot,
|
|
194
588
|
localRoot: input.localRoot,
|
|
195
589
|
});
|
|
590
|
+
// FR7: sibling to the board purge — clear only confirmed OpenPlanr-owned stale
|
|
591
|
+
// scratch (its own per-cycle lifecycle, kept out of purgeBoardMachineLocalCaches).
|
|
592
|
+
const scratch = await purgeAbandonedOperatingScratch({
|
|
593
|
+
projectRoot: input.projectRoot,
|
|
594
|
+
localRoot: input.localRoot,
|
|
595
|
+
});
|
|
196
596
|
return {
|
|
197
597
|
removed: removed.length +
|
|
198
598
|
sessions.removed +
|
|
199
599
|
board.removedAdvisorSessions +
|
|
200
|
-
board.removedIncrementalBaselines
|
|
600
|
+
board.removedIncrementalBaselines +
|
|
601
|
+
scratch.removed,
|
|
201
602
|
evidence: { removed: removed.length, entries: removed },
|
|
202
603
|
sessions,
|
|
203
604
|
adapterSessions: { removed: board.removedAdvisorSessions },
|
|
204
605
|
incrementalBaselines: { removed: board.removedIncrementalBaselines },
|
|
606
|
+
scratch: { removed: scratch.removed, cycles: scratch.cycles },
|
|
205
607
|
};
|
|
206
608
|
}
|
|
207
609
|
function integrityKeyPath(projectRoot, localRoot) {
|
|
@@ -892,7 +1294,31 @@ export async function operateAdapterLifecycle(input) {
|
|
|
892
1294
|
? recoverableSession.roleMandates
|
|
893
1295
|
: await (async () => {
|
|
894
1296
|
const roots = await deriveOperatingMandateRoots(input.projectRoot);
|
|
895
|
-
|
|
1297
|
+
// FR12 (SPEC-005 T-003): the native harness prepare path is the one real
|
|
1298
|
+
// runs use, so it must thread the SAME shared research guidance the inline
|
|
1299
|
+
// dispatch path does — otherwise native-runtime agents receive no FR12
|
|
1300
|
+
// targeting at all. Build the ONE shared, citation-bearing bootstrap map
|
|
1301
|
+
// once per cycle here (cached per project+head) and reference it from every
|
|
1302
|
+
// advisor role's mandate below, alongside the graceful per-role research
|
|
1303
|
+
// budget. The Chair is prepared alone (`['chair']`), never mixed with
|
|
1304
|
+
// advisors, so it derives an empty advisor set, builds no map, and its
|
|
1305
|
+
// mandate stays byte-identical. The map is body-free targeting layered over
|
|
1306
|
+
// the immutable mandate — never an evidence-pack input, never a size ceiling,
|
|
1307
|
+
// never a cap on what a lens may examine.
|
|
1308
|
+
const advisorRoles = roles.filter((roleId) => roleId !== 'chair');
|
|
1309
|
+
const bootstrapMap = advisorRoles.length > 0 ? await buildOperatingBootstrapMap(input.projectRoot) : null;
|
|
1310
|
+
const built = await Promise.all(roles.map(async (roleId) => [
|
|
1311
|
+
roleId,
|
|
1312
|
+
await buildOperatingMandate({
|
|
1313
|
+
roleId,
|
|
1314
|
+
roots,
|
|
1315
|
+
runtime,
|
|
1316
|
+
...(roleId !== 'chair' && bootstrapMap ? { bootstrapMap } : {}),
|
|
1317
|
+
...(roleId !== 'chair'
|
|
1318
|
+
? { researchBudgetMs: DEFAULT_OPERATING_ROLE_RESEARCH_BUDGET_MS }
|
|
1319
|
+
: {}),
|
|
1320
|
+
}),
|
|
1321
|
+
]));
|
|
896
1322
|
return Object.fromEntries(built);
|
|
897
1323
|
})();
|
|
898
1324
|
// Every requested role dispatches; the bound role set is exactly `roles`.
|
|
@@ -960,6 +1386,104 @@ export async function operateAdapterLifecycle(input) {
|
|
|
960
1386
|
cycleId: input.cycleId,
|
|
961
1387
|
localRoot: input.localRoot,
|
|
962
1388
|
}, session);
|
|
1389
|
+
if (input.action === 'heartbeat') {
|
|
1390
|
+
// FR2: renew the cycle session's lease WITHOUT recording a role result. A
|
|
1391
|
+
// slow lens can hold the session open — extending `expiresAt` by the same
|
|
1392
|
+
// configured lease duration — while siblings keep their already-recorded work
|
|
1393
|
+
// instead of the shared lease lapsing and stranding it. This is the sanctioned
|
|
1394
|
+
// renewal path; raising `adapterLeaseDurationMs` is explicitly not (SPEC-005).
|
|
1395
|
+
// It carries no `--role`/stdin, changes no role's recorded/pending status, and
|
|
1396
|
+
// is safe to call concurrently by any coordinator holding the current lease:
|
|
1397
|
+
// it goes through the same per-cycle session serialization as `record`, so it
|
|
1398
|
+
// can never clobber a concurrent record's `recordedRoles`.
|
|
1399
|
+
if (!['prepared', 'recording'].includes(session.state)) {
|
|
1400
|
+
throw new OperateError('E_OPERATE_STATE_INVALID', `Adapter heartbeat is not valid after the session is ${session.state}.`, { recoveryCommand: retryRunCommand(session) });
|
|
1401
|
+
}
|
|
1402
|
+
const renewed = await withInProcessSessionLock(target, () => commitSessionWrite(target, {
|
|
1403
|
+
projectRoot: input.projectRoot,
|
|
1404
|
+
cycleId: input.cycleId,
|
|
1405
|
+
localRoot: input.localRoot,
|
|
1406
|
+
lease: input.lease,
|
|
1407
|
+
idempotencyKey: input.idempotencyKey,
|
|
1408
|
+
evidenceDigest: input.evidenceDigest,
|
|
1409
|
+
nowMs,
|
|
1410
|
+
apply: (current) => ({
|
|
1411
|
+
...current,
|
|
1412
|
+
expiresAt: new Date(nowMs + leaseDurationMs).toISOString(),
|
|
1413
|
+
}),
|
|
1414
|
+
}));
|
|
1415
|
+
return {
|
|
1416
|
+
session: adapterSessionSummary(renewed),
|
|
1417
|
+
leaseStatus: adapterLeaseStatus(renewed, nowMs),
|
|
1418
|
+
handoff: await adapterHandoff(renewed),
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
if (input.action === 'abandon') {
|
|
1422
|
+
// SPEC-005 T-020 (FR13) — the runtime-side governed terminal path. The
|
|
1423
|
+
// orchestrating runtime, which dispatched this lens and holds the session
|
|
1424
|
+
// lease, invokes this when a dispatched lens exceeded its budget and never
|
|
1425
|
+
// returned (see the skill/command runtime workflow). The engine never spawned
|
|
1426
|
+
// the agent, so it cannot time it out unilaterally; this explicit governed
|
|
1427
|
+
// action, with a required reason, is the smallest honest substitute. It marks
|
|
1428
|
+
// ONE still-pending lens terminal `not_evaluated`, persists the governed gap
|
|
1429
|
+
// (a `gap.open` event carrying the reason), and lets the board consolidate
|
|
1430
|
+
// without it — its recorded siblings are untouched and the Chair names it as a
|
|
1431
|
+
// gap it must not synthesize around.
|
|
1432
|
+
if (!['prepared', 'recording'].includes(session.state)) {
|
|
1433
|
+
throw new OperateError('E_OPERATE_STATE_INVALID', `Adapter abandon is not valid after the session is ${session.state}.`, { recoveryCommand: retryRunCommand(session) });
|
|
1434
|
+
}
|
|
1435
|
+
if (!input.role) {
|
|
1436
|
+
throw new OperateError('E_OPERATE_CONFIG_INVALID', 'Adapter abandon requires --role naming the stalled lens.');
|
|
1437
|
+
}
|
|
1438
|
+
const reason = input.reason?.trim();
|
|
1439
|
+
if (!reason) {
|
|
1440
|
+
throw new OperateError('E_OPERATE_CONFIG_INVALID', 'Adapter abandon requires --reason recording why the lens is not_evaluated.');
|
|
1441
|
+
}
|
|
1442
|
+
if (!session.roles.includes(input.role)) {
|
|
1443
|
+
throw new OperateError('E_OPERATE_ADVISOR_ISOLATION', `Role ${input.role} was not bound by adapter prepare.`);
|
|
1444
|
+
}
|
|
1445
|
+
if (session.recordedRoles.includes(input.role)) {
|
|
1446
|
+
throw new OperateError('E_OPERATE_STATE_INVALID', `Role ${input.role} already recorded a result and cannot be abandoned.`, { recoveryCommand: retryRunCommand(session) });
|
|
1447
|
+
}
|
|
1448
|
+
// Persist the governed terminal gap first (committed state is the source of
|
|
1449
|
+
// truth the later `run` continuation reconstructs from), then mark the role
|
|
1450
|
+
// terminal in the lease-bound session so finalize no longer waits on it and
|
|
1451
|
+
// the handoff renders it `not-evaluated`. Idempotent: re-abandoning a role
|
|
1452
|
+
// already governed is a no-op that returns the same terminal handoff.
|
|
1453
|
+
await governTerminalNotEvaluated({
|
|
1454
|
+
projectRoot: input.projectRoot,
|
|
1455
|
+
localRoot: input.localRoot,
|
|
1456
|
+
cycleId: input.cycleId,
|
|
1457
|
+
roleId: input.role,
|
|
1458
|
+
reason,
|
|
1459
|
+
correlationId: input.idempotencyKey,
|
|
1460
|
+
});
|
|
1461
|
+
const updated = await withInProcessSessionLock(target, () => commitSessionWrite(target, {
|
|
1462
|
+
projectRoot: input.projectRoot,
|
|
1463
|
+
cycleId: input.cycleId,
|
|
1464
|
+
localRoot: input.localRoot,
|
|
1465
|
+
lease: input.lease,
|
|
1466
|
+
idempotencyKey: input.idempotencyKey,
|
|
1467
|
+
evidenceDigest: input.evidenceDigest,
|
|
1468
|
+
nowMs,
|
|
1469
|
+
apply: (current) => ({
|
|
1470
|
+
...current,
|
|
1471
|
+
state: 'recording',
|
|
1472
|
+
notEvaluatedRoles: {
|
|
1473
|
+
...(current.notEvaluatedRoles ?? {}),
|
|
1474
|
+
[input.role]: reason,
|
|
1475
|
+
},
|
|
1476
|
+
}),
|
|
1477
|
+
}));
|
|
1478
|
+
return {
|
|
1479
|
+
abandoned: input.role,
|
|
1480
|
+
notEvaluated: true,
|
|
1481
|
+
reason,
|
|
1482
|
+
session: adapterSessionSummary(updated),
|
|
1483
|
+
leaseStatus: adapterLeaseStatus(updated, nowMs),
|
|
1484
|
+
handoff: await adapterHandoff(updated),
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
963
1487
|
if (input.action === 'resume') {
|
|
964
1488
|
if (!['prepared', 'recording'].includes(session.state)) {
|
|
965
1489
|
throw new OperateError('E_OPERATE_STATE_INVALID', `Adapter resume is not valid after the session is ${session.state}.`, { recoveryCommand: retryRunCommand(session) });
|
|
@@ -999,11 +1523,20 @@ export async function operateAdapterLifecycle(input) {
|
|
|
999
1523
|
if (!session.roles.includes(input.role)) {
|
|
1000
1524
|
throw new OperateError('E_OPERATE_ADVISOR_ISOLATION', `Role ${input.role} was not bound by adapter prepare.`);
|
|
1001
1525
|
}
|
|
1526
|
+
// T-001 widened `record-required.next` to one record action per PENDING role,
|
|
1527
|
+
// so authorization is keyed by role, not by position: any pending role may
|
|
1528
|
+
// record the instant it returns, regardless of sibling order or completion
|
|
1529
|
+
// state (FR1). A role that is already recorded is not pending (so it has no
|
|
1530
|
+
// record action in `next`) but is still a legal idempotent replay target —
|
|
1531
|
+
// its identical-bytes no-op is enforced downstream by the resultDigest guard.
|
|
1532
|
+
// Only a role that is neither pending nor already recorded (e.g. a session
|
|
1533
|
+
// with no pending roles left) is rejected here, pointed at recovery.
|
|
1002
1534
|
const currentHandoff = await adapterHandoff(session);
|
|
1003
|
-
const authorizedRecord = currentHandoff.next.find((action) => ['adapter.record', 'harness.record'].includes(action.action));
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1535
|
+
const authorizedRecord = currentHandoff.next.find((action) => ['adapter.record', 'harness.record'].includes(action.action) && action.role === input.role);
|
|
1536
|
+
const alreadyRecorded = session.recordedRoles.includes(input.role);
|
|
1537
|
+
if (!authorizedRecord && !alreadyRecorded) {
|
|
1538
|
+
throw new OperateError('E_OPERATE_ADVISOR_ISOLATION', `Role ${input.role} has no pending adapter record action.`, {
|
|
1539
|
+
expectedRole: currentHandoff.next.find((action) => ['adapter.record', 'harness.record'].includes(action.action))?.role ?? null,
|
|
1007
1540
|
recoveryCommand: currentHandoff.recovery
|
|
1008
1541
|
.find((action) => ['adapter.resume', 'harness.resume'].includes(action.action))
|
|
1009
1542
|
?.argv.join(' '),
|
|
@@ -1173,20 +1706,75 @@ export async function operateAdapterLifecycle(input) {
|
|
|
1173
1706
|
throw new OperateError('E_OPERATE_ADVISOR_ISOLATION', `Role ${input.role} already recorded a different result.`);
|
|
1174
1707
|
}
|
|
1175
1708
|
await atomicPrivateWrite(path.join(path.dirname(target), `${input.cycleId}.${input.role}.json`), result);
|
|
1709
|
+
const advisorReport = session.protocolVersion === '1.4.0' &&
|
|
1710
|
+
submitted &&
|
|
1711
|
+
typeof submitted === 'object' &&
|
|
1712
|
+
!Array.isArray(submitted)
|
|
1713
|
+
? submitted
|
|
1714
|
+
: null;
|
|
1176
1715
|
if (session.protocolVersion === '1.4.0') {
|
|
1177
1716
|
await atomicPrivateWrite(path.join(path.dirname(target), `${input.cycleId}.${input.role}.response.json`), submitted);
|
|
1178
1717
|
}
|
|
1179
|
-
//
|
|
1180
|
-
//
|
|
1181
|
-
//
|
|
1182
|
-
//
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
await
|
|
1718
|
+
// The role's validated result is now durable in its own machine-local file
|
|
1719
|
+
// (survives a sibling stalling, restart, lease expiry, and resume). Merging it
|
|
1720
|
+
// into the shared session's `recordedRoles`, committing its canonical event,
|
|
1721
|
+
// and materializing its projection all mutate cycle-shared state, so they run
|
|
1722
|
+
// inside the per-cycle in-process serialization — a concurrent record of a
|
|
1723
|
+
// DIFFERENT role can never lose this one (FR1). A successful record also
|
|
1724
|
+
// refreshes the lease forward from now (T-008): steady progress across a
|
|
1725
|
+
// multi-role dispatch keeps the session alive without a separate keep-alive
|
|
1726
|
+
// call, while an idle session past the refreshed window still lapses.
|
|
1727
|
+
const target2 = target;
|
|
1728
|
+
const updated = await withInProcessSessionLock(target2, async () => {
|
|
1729
|
+
const merged = await commitSessionWrite(target2, {
|
|
1730
|
+
projectRoot: input.projectRoot,
|
|
1731
|
+
cycleId: input.cycleId,
|
|
1732
|
+
localRoot: input.localRoot,
|
|
1733
|
+
lease: input.lease,
|
|
1734
|
+
idempotencyKey: input.idempotencyKey,
|
|
1735
|
+
evidenceDigest: input.evidenceDigest,
|
|
1736
|
+
nowMs,
|
|
1737
|
+
apply: (current) => ({
|
|
1738
|
+
...current,
|
|
1739
|
+
state: 'recording',
|
|
1740
|
+
recordedRoles: [...new Set([...current.recordedRoles, input.role])].sort(),
|
|
1741
|
+
expiresAt: new Date(nowMs + leaseDurationMs).toISOString(),
|
|
1742
|
+
}),
|
|
1743
|
+
});
|
|
1744
|
+
// FR4/FR5: commit the canonical `advisory.recorded` event and refresh the
|
|
1745
|
+
// readable projection now, so `planr operate report`/`status` and
|
|
1746
|
+
// `.planr/operate/cycles/<id>/` reflect this lens' real analysis before
|
|
1747
|
+
// Chair runs — not only at finalize.
|
|
1748
|
+
await materializeRecordedRole({
|
|
1749
|
+
projectRoot: input.projectRoot,
|
|
1750
|
+
localRoot: input.localRoot,
|
|
1751
|
+
cycleId: input.cycleId,
|
|
1752
|
+
session: merged,
|
|
1753
|
+
result,
|
|
1754
|
+
advisorReport,
|
|
1755
|
+
});
|
|
1756
|
+
return merged;
|
|
1757
|
+
});
|
|
1758
|
+
// SPEC-005 T-019 (FR5/FR13): a citation-rejected lens commits a quiet result;
|
|
1759
|
+
// persist the governed gaps the citation gate opened so the Chair board — built
|
|
1760
|
+
// by a later `run` continuation, after this role is recorded and never
|
|
1761
|
+
// re-dispatched — can reconstruct the `citation-rejected` outcome from committed
|
|
1762
|
+
// state instead of rendering a false-clean `recorded-quiet`. Runs after the role
|
|
1763
|
+
// is materialized (its `advisory.recorded` event committed), acquiring the
|
|
1764
|
+
// event-store lock itself and idempotent per role, so a resume/retry never
|
|
1765
|
+
// duplicates. Empty on the normal healthy path (no citation rejection → no gaps).
|
|
1766
|
+
await persistRecordedRoleGaps({
|
|
1767
|
+
projectRoot: input.projectRoot,
|
|
1768
|
+
localRoot: input.localRoot,
|
|
1769
|
+
cycleId: input.cycleId,
|
|
1770
|
+
roleId: input.role,
|
|
1771
|
+
gaps: recordGaps,
|
|
1772
|
+
correlationId: input.idempotencyKey,
|
|
1773
|
+
});
|
|
1774
|
+
// FR7: the per-role result is now durably committed, so any OpenPlanr-owned
|
|
1775
|
+
// scratch this cycle used as a handoff is redundant. Clean only confirmed
|
|
1776
|
+
// owned scratch (a no-op on the normal stdin path, which writes none).
|
|
1777
|
+
await cleanOperatingScratch(resolveOperatingPaths(input.projectRoot, { localRoot: input.localRoot }), input.cycleId);
|
|
1190
1778
|
return {
|
|
1191
1779
|
recorded: input.role,
|
|
1192
1780
|
result,
|
|
@@ -1217,7 +1805,12 @@ export async function operateAdapterLifecycle(input) {
|
|
|
1217
1805
|
handoff: await adapterHandoff(session),
|
|
1218
1806
|
};
|
|
1219
1807
|
}
|
|
1220
|
-
|
|
1808
|
+
// T-020: a role is terminal for finalize once it recorded a result OR was
|
|
1809
|
+
// governed terminal `not_evaluated` (a lens abandoned after a genuine stall).
|
|
1810
|
+
// Only an in-flight lens — never dispatched to a terminal outcome — blocks
|
|
1811
|
+
// finalize, matching the T-001 wire contract that accepts an all-terminal board.
|
|
1812
|
+
const notEvaluatedRoles = session.notEvaluatedRoles ?? {};
|
|
1813
|
+
const missingRoles = session.roles.filter((role) => !session.recordedRoles.includes(role) && !(role in notEvaluatedRoles));
|
|
1221
1814
|
if (missingRoles.length > 0) {
|
|
1222
1815
|
throw new OperateError('E_OPERATE_ADVISOR_FAILED', `Adapter finalize is incomplete; missing roles: ${missingRoles.join(', ')}.`);
|
|
1223
1816
|
}
|
|
@@ -1301,6 +1894,9 @@ export async function operateAdapterLifecycle(input) {
|
|
|
1301
1894
|
});
|
|
1302
1895
|
const finalized = { ...session, state: 'finalized' };
|
|
1303
1896
|
await atomicPrivateWrite(target, finalized);
|
|
1897
|
+
// FR7: the cycle's advisory work is fully committed; clear any confirmed
|
|
1898
|
+
// OpenPlanr-owned scratch this cycle used as a handoff.
|
|
1899
|
+
await cleanOperatingScratch(resolveOperatingPaths(input.projectRoot, { localRoot: input.localRoot }), input.cycleId);
|
|
1304
1900
|
return {
|
|
1305
1901
|
session: adapterSessionSummary(finalized),
|
|
1306
1902
|
results: results.map((result) => ({
|
|
@@ -1313,4 +1909,102 @@ export async function operateAdapterLifecycle(input) {
|
|
|
1313
1909
|
handoff: await adapterHandoff(finalized),
|
|
1314
1910
|
};
|
|
1315
1911
|
}
|
|
1912
|
+
/**
|
|
1913
|
+
* SPEC-005 T-020 (FR13) — the OPERATOR escape, independent of a well-behaved
|
|
1914
|
+
* runtime. When a runtime dispatched a lens and then reported nothing at all — it
|
|
1915
|
+
* crashed, or does not implement `harness abandon` — the cycle is stranded at
|
|
1916
|
+
* `phase: advisors` on every continuation: the stalled lens is never recorded, so
|
|
1917
|
+
* it stays runnable-and-required and Chair is never assembled. The only prior
|
|
1918
|
+
* recourse was `cycles cancel`, which discards the whole cycle including the
|
|
1919
|
+
* siblings that DID record.
|
|
1920
|
+
*
|
|
1921
|
+
* This governed escape reaches a reviewable cycle without discarding it. It is
|
|
1922
|
+
* gated on a LAPSED lease — the legitimate signal that no runtime is actively
|
|
1923
|
+
* working the session — and an explicit operator confirmation. It never touches
|
|
1924
|
+
* the runtime lifecycle (`harness abandon`/`record`/`finalize`), so it works when
|
|
1925
|
+
* the runtime is gone. For each still-unrecorded advisor lens it commits the SAME
|
|
1926
|
+
* governed terminal `not_evaluated` gap the runtime path commits
|
|
1927
|
+
* (`governTerminalNotEvaluated`), so the next `run` continuation reconstructs the
|
|
1928
|
+
* lens `not_evaluated` from committed state and consolidation proceeds. Recorded
|
|
1929
|
+
* siblings are never read, re-dispatched, or altered. It fabricates nothing.
|
|
1930
|
+
*
|
|
1931
|
+
* It does not itself run consolidation — the operator reaches `reviewable` with a
|
|
1932
|
+
* following `planr operate run` (offline when no runtime is available), so the
|
|
1933
|
+
* choice of a real or offline Chair stays the operator's.
|
|
1934
|
+
*/
|
|
1935
|
+
export async function reapStalledOperatingRoles(input) {
|
|
1936
|
+
if (!input.confirmed) {
|
|
1937
|
+
throw new OperateError('E_OPERATE_AUTHORITY_REQUIRED', 'Abandoning a stalled lens is a governed action; re-run with --yes to confirm.');
|
|
1938
|
+
}
|
|
1939
|
+
const nowMs = (input.now?.() ?? new Date()).getTime();
|
|
1940
|
+
const store = new OperatingEventStore(input.projectRoot, { localRoot: input.localRoot });
|
|
1941
|
+
const state = await store.state();
|
|
1942
|
+
const cycle = state.cycles.find((record) => record.id === input.cycleId);
|
|
1943
|
+
if (!cycle || !['advising', 'blocked'].includes(cycle.state)) {
|
|
1944
|
+
throw new OperateError('E_OPERATE_STATE_INVALID', `Cycle ${input.cycleId} must be advising or blocked to abandon a stalled lens.`);
|
|
1945
|
+
}
|
|
1946
|
+
// Read the machine-local session RAW (never `readAdapterSession`, which throws on
|
|
1947
|
+
// a lapsed lease — the exact condition this escape keys on).
|
|
1948
|
+
const target = adapterSessionPath(input.projectRoot, input.cycleId, input.localRoot);
|
|
1949
|
+
const session = await readFile(target, 'utf8')
|
|
1950
|
+
.then((raw) => JSON.parse(raw))
|
|
1951
|
+
.catch(() => null);
|
|
1952
|
+
if (!session) {
|
|
1953
|
+
throw new OperateError('E_OPERATE_STATE_INVALID', `No adapter session exists for cycle ${input.cycleId}; nothing was dispatched to abandon.`);
|
|
1954
|
+
}
|
|
1955
|
+
if (session.phase !== 'advisors') {
|
|
1956
|
+
throw new OperateError('E_OPERATE_STATE_INVALID', 'Only an advisors-phase session can have stalled lenses abandoned.');
|
|
1957
|
+
}
|
|
1958
|
+
// The lapsed-lease gate: a still-live lease means the runtime may yet record or
|
|
1959
|
+
// heartbeat, so the operator must not race it. Once the lease has lapsed, the
|
|
1960
|
+
// runtime is not governing the session and the operator may terminate the
|
|
1961
|
+
// lenses it left unrecorded.
|
|
1962
|
+
if (!sessionExpired(session, nowMs)) {
|
|
1963
|
+
throw new OperateError('E_OPERATE_ADVISOR_ISOLATION', 'The adapter lease has not lapsed; the runtime may still be working. Wait for the lease to ' +
|
|
1964
|
+
'expire, or use `planr operate harness abandon` with the active lease.', { recoveryCommand: retryRunCommand(session) });
|
|
1965
|
+
}
|
|
1966
|
+
const recorded = new Set(session.recordedRoles);
|
|
1967
|
+
const alreadyNotEvaluated = new Set(Object.keys(session.notEvaluatedRoles ?? {}));
|
|
1968
|
+
const candidates = session.roles.filter((role) => role !== 'chair' && !recorded.has(role) && !alreadyNotEvaluated.has(role));
|
|
1969
|
+
const targets = input.role ? candidates.filter((role) => role === input.role) : candidates;
|
|
1970
|
+
if (input.role && !session.roles.includes(input.role)) {
|
|
1971
|
+
throw new OperateError('E_OPERATE_CONFIG_INVALID', `Role ${input.role} was not part of the stalled session.`);
|
|
1972
|
+
}
|
|
1973
|
+
if (input.role && recorded.has(input.role)) {
|
|
1974
|
+
throw new OperateError('E_OPERATE_STATE_INVALID', `Role ${input.role} already recorded a result and cannot be abandoned.`);
|
|
1975
|
+
}
|
|
1976
|
+
const reason = input.reason?.trim() ||
|
|
1977
|
+
`Operator terminated this lens not_evaluated after its adapter lease lapsed on ` +
|
|
1978
|
+
`${new Date(nowMs).toISOString()} with no recorded result.`;
|
|
1979
|
+
const reaped = [];
|
|
1980
|
+
const notEvaluated = { ...(session.notEvaluatedRoles ?? {}) };
|
|
1981
|
+
for (const roleId of targets) {
|
|
1982
|
+
await governTerminalNotEvaluated({
|
|
1983
|
+
projectRoot: input.projectRoot,
|
|
1984
|
+
localRoot: input.localRoot,
|
|
1985
|
+
cycleId: input.cycleId,
|
|
1986
|
+
roleId,
|
|
1987
|
+
reason,
|
|
1988
|
+
correlationId: session.idempotencyKey,
|
|
1989
|
+
});
|
|
1990
|
+
// The committed gap is the source of truth; look it back up so the caller can
|
|
1991
|
+
// reference the durable governed gap id.
|
|
1992
|
+
const gap = (await store.state()).dataGaps.find((entry) => Array.isArray(entry.affectedRoles) && entry.affectedRoles.includes(roleId));
|
|
1993
|
+
reaped.push({ roleId, reason, gapId: String(gap?.id ?? '') });
|
|
1994
|
+
notEvaluated[roleId] = reason;
|
|
1995
|
+
}
|
|
1996
|
+
// Best-effort: reflect the terminal status in the (already lapsed) machine-local
|
|
1997
|
+
// session so an inspecting `harness resume`/handoff reads honestly. The committed
|
|
1998
|
+
// gap above — not this file — is what unblocks consolidation, so a failure here
|
|
1999
|
+
// is non-fatal.
|
|
2000
|
+
if (reaped.length > 0) {
|
|
2001
|
+
await atomicPrivateWrite(target, { ...session, notEvaluatedRoles: notEvaluated }).catch(() => undefined);
|
|
2002
|
+
}
|
|
2003
|
+
return {
|
|
2004
|
+
cycleId: input.cycleId,
|
|
2005
|
+
reaped,
|
|
2006
|
+
alreadyRecorded: [...recorded].sort(),
|
|
2007
|
+
next: [`planr operate run --cycle-id ${input.cycleId} --offline --yes`],
|
|
2008
|
+
};
|
|
2009
|
+
}
|
|
1316
2010
|
//# sourceMappingURL=maintenance.js.map
|