kiokuko-dsh 0.1.21 → 0.1.22

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.
@@ -31,11 +31,12 @@ import { curateMemoryCandidates } from '../memory/curator.js';
31
31
  import { checkpointDshMemory } from '../memory/scoped-memory.js';
32
32
  import { LedgerStore } from '../ledger/store.js';
33
33
  import { ENNO_APPLICABLE_TASK_TYPES } from '../enno-oduno/types.js';
34
- import { commitExpectedFailure, ennoReceiptOperation, isExpectedTurnFailure, phaseForOperation, prepareTurnIntent, readPendingOutbox, readTurnSeal, markOutboxObservedInTransaction, appliedTurnOutcome, replacePendingOutboxMessageInTransaction, supersedeOutboxAtOrBeforeRevisionInTransaction, } from './turn-process.js';
34
+ import { commitExpectedFailure, ennoReceiptOperation, isExpectedTurnFailure, phaseForOperation, prepareTurnIntent, readPendingOutbox, readTurnSeal, markOutboxObservedInTransaction, appliedTurnOutcome, replacePendingOutboxMessageInTransaction, supersedeBoundaryJobsAtOrBeforeRevisionInTransaction, supersedeOutboxAtOrBeforeRevisionInTransaction, } from './turn-process.js';
35
35
  import { backupInputClaimInTransaction, markClaimProgressInTransaction, settleInputClaimInTransaction, takeRecoverableInputClaimInTransaction, } from './input-claim.js';
36
36
  import { DshSessionLogMirror } from './session-log-mirror.js';
37
37
  import { DshBoundaryWorker } from './boundary-worker.js';
38
38
  import { DshSessionLogExportService } from './session-log-export.js';
39
+ import { claimAutomaticContinuationInTransaction, claimBoundaryEffectInTransaction, claimLoopRecoveryQuestionInTransaction, ennoInstructionDigest, resetBoundaryEffectGuardInTransaction, resetLoopGuardForUserInTransaction, } from './loop-guard.js';
39
40
  function sessionEventSource(value) {
40
41
  const source = value;
41
42
  if (typeof source?.snapshotEvents !== 'function') {
@@ -94,12 +95,49 @@ function isHumanMessage(value) {
94
95
  function pluginContinuationId(value) {
95
96
  const message = objectRecord(value);
96
97
  const source = objectRecord(message?.source);
97
- if (source?.kind !== 'plugin' || source.plugin !== 'kiokuko-dsh' || source.form !== 'continuation')
98
+ if (source?.kind !== 'plugin' || source.plugin !== 'kiokuko-dsh'
99
+ || (source.form !== 'continuation' && source.form !== 'loop-recovery'))
98
100
  return undefined;
99
101
  return typeof source.deliveryId === 'string' && /^[0-9a-f]{64}$/u.test(source.deliveryId)
100
102
  ? source.deliveryId
101
103
  : undefined;
102
104
  }
105
+ function isLoopRecoveryMessage(value) {
106
+ const source = objectRecord(objectRecord(value)?.source);
107
+ return source?.kind === 'plugin' && source.plugin === 'kiokuko-dsh' && source.form === 'loop-recovery';
108
+ }
109
+ function recoveryMessage(continuationId, answer) {
110
+ return Object.freeze({
111
+ id: continuationId,
112
+ role: 'user',
113
+ content: [{
114
+ type: 'text',
115
+ text: `The user reviewed the stopped Kiokuko loop and supplied this recovery instruction:\n\n${answer}`,
116
+ }],
117
+ source: { kind: 'plugin', plugin: 'kiokuko-dsh', form: 'loop-recovery', deliveryId: continuationId },
118
+ });
119
+ }
120
+ function boundedMessageText(value) {
121
+ const content = objectRecord(value)?.content;
122
+ if (!Array.isArray(content))
123
+ return undefined;
124
+ const block = content.map(objectRecord).find((candidate) => candidate?.type === 'text' && typeof candidate.text === 'string');
125
+ return typeof block?.text === 'string' ? block.text.slice(0, 2_000) : undefined;
126
+ }
127
+ function boundedUtf8Text(value, maximumBytes) {
128
+ if (Buffer.byteLength(value, 'utf8') <= maximumBytes)
129
+ return value;
130
+ let result = '';
131
+ let bytes = 0;
132
+ for (const point of value) {
133
+ const size = Buffer.byteLength(point, 'utf8');
134
+ if (bytes + size > maximumBytes)
135
+ break;
136
+ result += point;
137
+ bytes += size;
138
+ }
139
+ return result;
140
+ }
103
141
  function eventContinuationId(data) {
104
142
  const direct = pluginContinuationId(data);
105
143
  if (direct !== undefined)
@@ -640,10 +678,20 @@ export function createDshHostAdapter(ctx, options = {}) {
640
678
  if (humanPresent) {
641
679
  const previous = currentSession(event.sessionId);
642
680
  const state = previous === undefined ? undefined : states.get(previous.runId);
643
- if (state !== undefined) {
681
+ if (previous !== undefined) {
644
682
  try {
645
683
  await runtime.withDatabase((database) => withImmediateTransaction(database, () => {
646
- supersedeOutboxAtOrBeforeRevisionInTransaction(database, event.sessionId, state.revision);
684
+ if (state !== undefined) {
685
+ const now = options.now?.() ?? new Date().toISOString();
686
+ supersedeOutboxAtOrBeforeRevisionInTransaction(database, event.sessionId, state.revision, now);
687
+ supersedeBoundaryJobsAtOrBeforeRevisionInTransaction(database, event.sessionId, state.revision, now);
688
+ }
689
+ resetLoopGuardForUserInTransaction(database, {
690
+ runId: previous.runId,
691
+ dshSessionId: event.sessionId,
692
+ resolution: 'manual_user',
693
+ ...(options.now === undefined ? {} : { now: options.now() }),
694
+ });
647
695
  }));
648
696
  }
649
697
  catch {
@@ -752,9 +800,12 @@ export function createDshHostAdapter(ctx, options = {}) {
752
800
  terminalizeLedgerRunInTransaction(database, runId, 'cancelled');
753
801
  return undefined;
754
802
  }
803
+ const automaticMessage = event.nativeMessages?.find((message) => pluginContinuationId(message) !== undefined && !isLoopRecoveryMessage(message));
804
+ const automaticClaimId = automaticMessage === undefined ? undefined : pluginContinuationId(automaticMessage);
755
805
  const decision = decideDshContinuation(database, {
756
806
  dshSessionId: event.sessionId,
757
807
  cwd: event.cwd,
808
+ ...(automaticClaimId === undefined ? {} : { claimId: automaticClaimId }),
758
809
  }, runId);
759
810
  if (!decision.continue || decision.runId !== runId) {
760
811
  throw new KiokukoError('CONFLICT', decision.warning ?? 'The active Enno-Oduno run cannot be resumed by this DSH session');
@@ -1113,6 +1164,83 @@ export function createDshHostAdapter(ctx, options = {}) {
1113
1164
  signal,
1114
1165
  });
1115
1166
  const readBoundaryState = async (item) => (runtime.withDatabase((database) => stateForRun(database, item)));
1167
+ const askForRecoveryInstruction = async (input) => {
1168
+ const agent = input.item.nativeAgent ?? agents?.get(input.item.agentId);
1169
+ if (userQuestions === undefined || agent === undefined)
1170
+ return undefined;
1171
+ try {
1172
+ const result = await userQuestions.ask({
1173
+ questions: [{
1174
+ id: input.questionId,
1175
+ header: 'Kiokuko stopped',
1176
+ question: input.title,
1177
+ detail: input.detail,
1178
+ }],
1179
+ agent,
1180
+ });
1181
+ const answer = result.answers[0];
1182
+ if (answer === undefined || answer.id !== input.questionId)
1183
+ return undefined;
1184
+ const value = answer.custom?.trim() || answer.selected[0]?.trim();
1185
+ return value === undefined || value.length === 0 ? undefined : boundedUtf8Text(value, 8 * 1024);
1186
+ }
1187
+ catch {
1188
+ // A broken/dismissed question surface must never become another retry
1189
+ // loop. The durable waiting_user state remains the recovery boundary.
1190
+ return undefined;
1191
+ }
1192
+ };
1193
+ const loopRecoveryDetail = (snapshot, state, reason) => {
1194
+ const workUnitId = state.directive?.workUnit?.id ?? null;
1195
+ const workUnit = workUnitId === null
1196
+ ? null
1197
+ : snapshot.workUnits.find((candidate) => candidate.workUnit.id === workUnitId) ?? null;
1198
+ const latestVerifier = snapshot.finalEvidence.at(-1);
1199
+ return [
1200
+ `Host status: phase=${snapshot.status}; nextAction=${state.nextAction}; role=${state.currentRole ?? 'none'}.`,
1201
+ `Revision=${snapshot.revision}; mutationRevision=${snapshot.mutationRevision}; attempts=${snapshot.attempts}/${snapshot.contract.maxAttempts}.`,
1202
+ workUnit === null
1203
+ ? 'WorkUnit: none.'
1204
+ : `WorkUnit ${workUnit.workUnit.id}: ${workUnit.workUnit.objective} (status=${workUnit.status}, attempts=${workUnit.attemptCount}).`,
1205
+ latestVerifier === undefined
1206
+ ? 'Latest verifier: none.'
1207
+ : `Latest verifier ${latestVerifier.verifier.id}: ${latestVerifier.status}.`,
1208
+ reason,
1209
+ 'Enter the actual current handling status and the concrete instruction to execute next. If work should stop, say so explicitly.',
1210
+ ].join('\n');
1211
+ };
1212
+ const guardBoundaryEffect = async (item, job) => {
1213
+ const guarded = await runtime.withDatabase((database) => withImmediateTransaction(database, () => {
1214
+ const snapshot = readEnnoSnapshot(database, {
1215
+ runId: item.runId, workspace: item.workspace, orchestrationId: item.orchestrationId,
1216
+ });
1217
+ const state = stateForSnapshot(snapshot);
1218
+ const claim = claimBoundaryEffectInTransaction(database, job, ennoInstructionDigest(snapshot, state.directive), options.now?.() ?? new Date().toISOString());
1219
+ return { snapshot, state, claim };
1220
+ }));
1221
+ if (guarded.claim.decision === 'deliver')
1222
+ return true;
1223
+ const answer = await askForRecoveryInstruction({
1224
+ item,
1225
+ questionId: `effect-${job.jobId.slice(0, 16)}`,
1226
+ title: 'Kiokuko stopped before a fourth stateful boundary operation without progress.',
1227
+ detail: loopRecoveryDetail(guarded.snapshot, guarded.state, `${job.kind} completed or was re-entered three times without authoritative Enno progress.`),
1228
+ });
1229
+ if (answer === undefined) {
1230
+ await sessionMirror.markWaitingUser(item.sessionId);
1231
+ return false;
1232
+ }
1233
+ await runtime.withDatabase((database) => withImmediateTransaction(database, () => {
1234
+ resetBoundaryEffectGuardInTransaction(database, job, options.now?.() ?? new Date().toISOString());
1235
+ resetLoopGuardForUserInTransaction(database, {
1236
+ runId: item.runId,
1237
+ dshSessionId: item.sessionId,
1238
+ resolution: 'manual_user',
1239
+ ...(options.now === undefined ? {} : { now: options.now() }),
1240
+ });
1241
+ }));
1242
+ return true;
1243
+ };
1116
1244
  const confirmBoundary = async (item, state) => {
1117
1245
  const confirmation = state.directive?.userFacingConfirmation;
1118
1246
  if (confirmation === undefined || state.contractRevision === null)
@@ -1212,6 +1340,23 @@ export function createDshHostAdapter(ctx, options = {}) {
1212
1340
  return readBoundaryState(item);
1213
1341
  },
1214
1342
  validateBoundary: async ({ event }) => { await assertTurnBoundary(event); },
1343
+ requestLoopRecovery: async ({ event, state, automaticCount }) => {
1344
+ const item = currentForAgentEvent(event.agent.id, event.agent.sessionId, event.turn, event.agent.nativeSession, event.agent.nativeAgent);
1345
+ if (item === undefined)
1346
+ return undefined;
1347
+ const snapshot = await runtime.withDatabase((database) => readEnnoSnapshot(database, {
1348
+ runId: item.runId, workspace: item.workspace, orchestrationId: item.orchestrationId,
1349
+ }));
1350
+ const answer = await askForRecoveryInstruction({
1351
+ item,
1352
+ questionId: `legacy-loop-${item.runId.slice(0, 12)}`,
1353
+ title: 'Kiokuko stopped before a fourth identical automatic continuation.',
1354
+ detail: loopRecoveryDetail(snapshot, state, `The legacy turn controller continued the same instruction ${automaticCount} times without authoritative Enno progress.`),
1355
+ });
1356
+ if (answer === undefined)
1357
+ await sessionMirror.markWaitingUser(item.sessionId);
1358
+ return answer;
1359
+ },
1215
1360
  confirmUser: async ({ event, state }) => {
1216
1361
  const item = currentForAgentEvent(event.agent.id, event.agent.sessionId, event.turn, event.agent.nativeSession, event.agent.nativeAgent);
1217
1362
  if (item === undefined)
@@ -1249,7 +1394,43 @@ export function createDshHostAdapter(ctx, options = {}) {
1249
1394
  if (item === undefined || item.closed || item.runId !== job.runId || item.turn < job.nativeTurn) {
1250
1395
  throw new Error('kiokuko-dsh boundary job has no exact live run binding');
1251
1396
  }
1252
- if (job.kind.startsWith('retry_') || job.kind === 'ask_akinator') {
1397
+ if ((job.kind === 'confirmation' || job.kind === 'final_verification' || job.kind === 'advisory')
1398
+ && !await guardBoundaryEffect(item, job)) {
1399
+ return { kind: 'waiting_user' };
1400
+ }
1401
+ if (job.kind.startsWith('retry_')) {
1402
+ return { kind: 'completed', nextKind: 'delivery' };
1403
+ }
1404
+ if (job.kind === 'ask_akinator') {
1405
+ const snapshot = await runtime.withDatabase((database) => readEnnoSnapshot(database, {
1406
+ runId: item.runId, workspace: item.workspace, orchestrationId: item.orchestrationId,
1407
+ }));
1408
+ const state = stateForSnapshot(snapshot);
1409
+ const pending = await runtime.withDatabase((database) => readPendingOutbox(database, item.sessionId)
1410
+ .find((candidate) => candidate.receiptId === job.receiptId));
1411
+ const validationFact = boundedMessageText(pending?.message);
1412
+ const answer = await askForRecoveryInstruction({
1413
+ item,
1414
+ questionId: `validation-${job.receiptId.slice(0, 16)}`,
1415
+ title: 'Kiokuko validation repeatedly failed and needs your instruction.',
1416
+ detail: loopRecoveryDetail(snapshot, state, validationFact ?? 'The same validation constraint was rejected repeatedly.'),
1417
+ });
1418
+ if (answer === undefined) {
1419
+ await sessionMirror.markWaitingUser(item.sessionId);
1420
+ return { kind: 'waiting_user' };
1421
+ }
1422
+ await runtime.withDatabase((database) => withImmediateTransaction(database, () => {
1423
+ resetLoopGuardForUserInTransaction(database, {
1424
+ runId: item.runId,
1425
+ dshSessionId: item.sessionId,
1426
+ resolution: 'manual_user',
1427
+ ...(options.now === undefined ? {} : { now: options.now() }),
1428
+ });
1429
+ const outbox = readPendingOutbox(database, item.sessionId).find((candidate) => candidate.receiptId === job.receiptId);
1430
+ if (outbox !== undefined) {
1431
+ replacePendingOutboxMessageInTransaction(database, job.receiptId, recoveryMessage(outbox.continuationId, answer), options.now?.() ?? new Date().toISOString());
1432
+ }
1433
+ }));
1253
1434
  return { kind: 'completed', nextKind: 'delivery' };
1254
1435
  }
1255
1436
  if (job.kind === 'classify_boundary') {
@@ -1316,6 +1497,59 @@ export function createDshHostAdapter(ctx, options = {}) {
1316
1497
  }
1317
1498
  catch { /* non-vetoing cache */ }
1318
1499
  },
1500
+ beforeDelivery: async (job, outbox) => {
1501
+ if (isLoopRecoveryMessage(outbox.message))
1502
+ return 'deliver';
1503
+ const item = currentSession(job.dshSessionId);
1504
+ if (item === undefined || item.closed || item.runId !== job.runId)
1505
+ return 'superseded';
1506
+ const guarded = await runtime.withDatabase((database) => withImmediateTransaction(database, () => {
1507
+ const snapshot = readEnnoSnapshot(database, {
1508
+ runId: item.runId, workspace: item.workspace, orchestrationId: item.orchestrationId,
1509
+ });
1510
+ const state = stateForSnapshot(snapshot);
1511
+ const claim = claimAutomaticContinuationInTransaction(database, {
1512
+ claimId: outbox.continuationId,
1513
+ runId: item.runId,
1514
+ dshSessionId: item.sessionId,
1515
+ instructionDigest: ennoInstructionDigest(snapshot, state.directive),
1516
+ ...(options.now === undefined ? {} : { now: options.now() }),
1517
+ });
1518
+ const shouldAsk = claim.decision === 'wait_user'
1519
+ && claimLoopRecoveryQuestionInTransaction(database, claim.claimId, options.now?.() ?? new Date().toISOString());
1520
+ return { snapshot, state, claim, shouldAsk };
1521
+ }));
1522
+ if (guarded.claim.decision === 'deliver')
1523
+ return 'deliver';
1524
+ if (!guarded.shouldAsk) {
1525
+ await sessionMirror.markWaitingUser(item.sessionId);
1526
+ return 'waiting_user';
1527
+ }
1528
+ const answer = await askForRecoveryInstruction({
1529
+ item,
1530
+ questionId: `loop-${guarded.claim.claimId.slice(0, 16)}`,
1531
+ title: 'Kiokuko stopped before a fourth identical automatic continuation.',
1532
+ detail: loopRecoveryDetail(guarded.snapshot, guarded.state, [
1533
+ `The same instruction was automatically continued ${guarded.claim.ordinal - 1} times without authoritative Enno progress.`,
1534
+ boundedMessageText(outbox.message),
1535
+ ].filter((value) => value !== undefined).join('\n')),
1536
+ });
1537
+ if (answer === undefined) {
1538
+ await sessionMirror.markWaitingUser(item.sessionId);
1539
+ return 'waiting_user';
1540
+ }
1541
+ await runtime.withDatabase((database) => withImmediateTransaction(database, () => {
1542
+ resetLoopGuardForUserInTransaction(database, {
1543
+ runId: item.runId,
1544
+ dshSessionId: item.sessionId,
1545
+ resolution: 'user_answer',
1546
+ claimId: guarded.claim.claimId,
1547
+ ...(options.now === undefined ? {} : { now: options.now() }),
1548
+ });
1549
+ replacePendingOutboxMessageInTransaction(database, job.receiptId, recoveryMessage(outbox.continuationId, answer), options.now?.() ?? new Date().toISOString());
1550
+ }));
1551
+ return 'deliver';
1552
+ },
1319
1553
  dispatch: async (job, outbox) => {
1320
1554
  const item = currentSession(job.dshSessionId);
1321
1555
  const nativeAgent = (boundaryAgents.get(job.dshSessionId) ?? agents?.get(job.dshSessionId));
@@ -1324,6 +1558,42 @@ export function createDshHostAdapter(ctx, options = {}) {
1324
1558
  }
1325
1559
  nativeAgent.steer(outbox.message);
1326
1560
  },
1561
+ onWaitingUser: async (job, error) => {
1562
+ const item = currentSession(job.dshSessionId);
1563
+ if (item === undefined || item.closed || item.runId !== job.runId)
1564
+ return false;
1565
+ await sessionMirror.markWaitingUser(item.sessionId);
1566
+ const snapshot = await runtime.withDatabase((database) => readEnnoSnapshot(database, {
1567
+ runId: item.runId, workspace: item.workspace, orchestrationId: item.orchestrationId,
1568
+ }));
1569
+ const answer = await askForRecoveryInstruction({
1570
+ item,
1571
+ questionId: `boundary-${job.jobId.slice(0, 16)}`,
1572
+ title: 'Kiokuko stopped after three boundary-processing failures.',
1573
+ detail: loopRecoveryDetail(snapshot, stateForSnapshot(snapshot), `Last boundary error: ${(error instanceof Error ? error.message : String(error)).slice(0, 2_000)}`),
1574
+ });
1575
+ if (answer === undefined)
1576
+ return false;
1577
+ await runtime.withDatabase((database) => withImmediateTransaction(database, () => {
1578
+ resetLoopGuardForUserInTransaction(database, {
1579
+ runId: item.runId,
1580
+ dshSessionId: item.sessionId,
1581
+ resolution: 'manual_user',
1582
+ ...(options.now === undefined ? {} : { now: options.now() }),
1583
+ });
1584
+ const outbox = readPendingOutbox(database, item.sessionId).find((candidate) => candidate.receiptId === job.receiptId);
1585
+ if (outbox !== undefined) {
1586
+ replacePendingOutboxMessageInTransaction(database, job.receiptId, recoveryMessage(outbox.continuationId, answer), options.now?.() ?? new Date().toISOString());
1587
+ }
1588
+ database.prepare(`
1589
+ UPDATE dsh_boundary_jobs
1590
+ SET status = 'pending', attempt_count = 0, available_at = ?,
1591
+ last_error_code = NULL, last_error_message = NULL, updated_at = ?
1592
+ WHERE job_id = ? AND status = 'waiting_user'
1593
+ `).run(options.now?.() ?? new Date().toISOString(), options.now?.() ?? new Date().toISOString(), job.jobId);
1594
+ }));
1595
+ return true;
1596
+ },
1327
1597
  });
1328
1598
  const rehydrateBoundarySession = async (nativeAgent) => {
1329
1599
  const nativeSession = nativeAgent.session ?? sessions?.get(nativeAgent.id);