kiokuko-dsh 0.1.21 → 0.1.23

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.
Files changed (43) hide show
  1. package/dist/client.cjs +23 -1
  2. package/dist/client.d.ts +9 -3
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/dsh/boundary-worker.d.ts +8 -1
  5. package/dist/dsh/boundary-worker.d.ts.map +1 -1
  6. package/dist/dsh/boundary-worker.js +100 -22
  7. package/dist/dsh/boundary-worker.js.map +1 -1
  8. package/dist/dsh/completion-report.d.ts +25 -0
  9. package/dist/dsh/completion-report.d.ts.map +1 -0
  10. package/dist/dsh/completion-report.js +88 -0
  11. package/dist/dsh/completion-report.js.map +1 -0
  12. package/dist/dsh/continuation.d.ts +1 -0
  13. package/dist/dsh/continuation.d.ts.map +1 -1
  14. package/dist/dsh/continuation.js +19 -5
  15. package/dist/dsh/continuation.js.map +1 -1
  16. package/dist/dsh/enno-controller.d.ts +6 -0
  17. package/dist/dsh/enno-controller.d.ts.map +1 -1
  18. package/dist/dsh/enno-controller.js +42 -14
  19. package/dist/dsh/enno-controller.js.map +1 -1
  20. package/dist/dsh/host-adapter.d.ts.map +1 -1
  21. package/dist/dsh/host-adapter.js +365 -19
  22. package/dist/dsh/host-adapter.js.map +1 -1
  23. package/dist/dsh/loop-guard.d.ts +43 -0
  24. package/dist/dsh/loop-guard.d.ts.map +1 -0
  25. package/dist/dsh/loop-guard.js +206 -0
  26. package/dist/dsh/loop-guard.js.map +1 -0
  27. package/dist/dsh/tools.d.ts.map +1 -1
  28. package/dist/dsh/tools.js +41 -20
  29. package/dist/dsh/tools.js.map +1 -1
  30. package/dist/dsh/turn-process.d.ts +15 -1
  31. package/dist/dsh/turn-process.d.ts.map +1 -1
  32. package/dist/dsh/turn-process.js +69 -11
  33. package/dist/dsh/turn-process.js.map +1 -1
  34. package/dist/model-tools/registry.d.ts +6 -0
  35. package/dist/model-tools/registry.d.ts.map +1 -1
  36. package/dist/model-tools/registry.js +35 -0
  37. package/dist/model-tools/registry.js.map +1 -1
  38. package/docs/database.md +14 -0
  39. package/docs/dsh-plugin.md +24 -6
  40. package/migrations/004_dsh_loop_guard.sql +46 -0
  41. package/migrations/005_dsh_completion_recovery.sql +118 -0
  42. package/package.json +1 -1
  43. package/skills/kiokuko-enno-oduno/SKILL.md +1 -0
@@ -31,11 +31,13 @@ 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, enqueueUnsubmittedTurn, 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
- import { DshBoundaryWorker } from './boundary-worker.js';
37
+ import { abortable, DshBoundaryWorker } from './boundary-worker.js';
38
38
  import { DshSessionLogExportService } from './session-log-export.js';
39
+ import { DshCompletionReporter } from './completion-report.js';
40
+ import { claimAutomaticContinuationInTransaction, claimBoundaryEffectInTransaction, claimLoopRecoveryQuestionInTransaction, ennoInstructionDigest, resetBoundaryEffectGuardInTransaction, resetLoopGuardForUserInTransaction, } from './loop-guard.js';
39
41
  function sessionEventSource(value) {
40
42
  const source = value;
41
43
  if (typeof source?.snapshotEvents !== 'function') {
@@ -94,12 +96,49 @@ function isHumanMessage(value) {
94
96
  function pluginContinuationId(value) {
95
97
  const message = objectRecord(value);
96
98
  const source = objectRecord(message?.source);
97
- if (source?.kind !== 'plugin' || source.plugin !== 'kiokuko-dsh' || source.form !== 'continuation')
99
+ if (source?.kind !== 'plugin' || source.plugin !== 'kiokuko-dsh'
100
+ || (source.form !== 'continuation' && source.form !== 'loop-recovery'))
98
101
  return undefined;
99
102
  return typeof source.deliveryId === 'string' && /^[0-9a-f]{64}$/u.test(source.deliveryId)
100
103
  ? source.deliveryId
101
104
  : undefined;
102
105
  }
106
+ function isLoopRecoveryMessage(value) {
107
+ const source = objectRecord(objectRecord(value)?.source);
108
+ return source?.kind === 'plugin' && source.plugin === 'kiokuko-dsh' && source.form === 'loop-recovery';
109
+ }
110
+ function recoveryMessage(continuationId, answer) {
111
+ return Object.freeze({
112
+ id: continuationId,
113
+ role: 'user',
114
+ content: [{
115
+ type: 'text',
116
+ text: `The user reviewed the stopped Kiokuko loop and supplied this recovery instruction:\n\n${answer}`,
117
+ }],
118
+ source: { kind: 'plugin', plugin: 'kiokuko-dsh', form: 'loop-recovery', deliveryId: continuationId },
119
+ });
120
+ }
121
+ function boundedMessageText(value) {
122
+ const content = objectRecord(value)?.content;
123
+ if (!Array.isArray(content))
124
+ return undefined;
125
+ const block = content.map(objectRecord).find((candidate) => candidate?.type === 'text' && typeof candidate.text === 'string');
126
+ return typeof block?.text === 'string' ? block.text.slice(0, 2_000) : undefined;
127
+ }
128
+ function boundedUtf8Text(value, maximumBytes) {
129
+ if (Buffer.byteLength(value, 'utf8') <= maximumBytes)
130
+ return value;
131
+ let result = '';
132
+ let bytes = 0;
133
+ for (const point of value) {
134
+ const size = Buffer.byteLength(point, 'utf8');
135
+ if (bytes + size > maximumBytes)
136
+ break;
137
+ result += point;
138
+ bytes += size;
139
+ }
140
+ return result;
141
+ }
103
142
  function eventContinuationId(data) {
104
143
  const direct = pluginContinuationId(data);
105
144
  if (direct !== undefined)
@@ -638,12 +677,23 @@ export function createDshHostAdapter(ctx, options = {}) {
638
677
  }),
639
678
  };
640
679
  if (humanPresent) {
680
+ boundaryWorker.cancelSession(event.sessionId);
641
681
  const previous = currentSession(event.sessionId);
642
682
  const state = previous === undefined ? undefined : states.get(previous.runId);
643
- if (state !== undefined) {
683
+ if (previous !== undefined) {
644
684
  try {
645
685
  await runtime.withDatabase((database) => withImmediateTransaction(database, () => {
646
- supersedeOutboxAtOrBeforeRevisionInTransaction(database, event.sessionId, state.revision);
686
+ if (state !== undefined) {
687
+ const now = options.now?.() ?? new Date().toISOString();
688
+ supersedeOutboxAtOrBeforeRevisionInTransaction(database, event.sessionId, state.revision, now);
689
+ supersedeBoundaryJobsAtOrBeforeRevisionInTransaction(database, event.sessionId, state.revision, now);
690
+ }
691
+ resetLoopGuardForUserInTransaction(database, {
692
+ runId: previous.runId,
693
+ dshSessionId: event.sessionId,
694
+ resolution: 'manual_user',
695
+ ...(options.now === undefined ? {} : { now: options.now() }),
696
+ });
647
697
  }));
648
698
  }
649
699
  catch {
@@ -752,9 +802,12 @@ export function createDshHostAdapter(ctx, options = {}) {
752
802
  terminalizeLedgerRunInTransaction(database, runId, 'cancelled');
753
803
  return undefined;
754
804
  }
805
+ const automaticMessage = event.nativeMessages?.find((message) => pluginContinuationId(message) !== undefined && !isLoopRecoveryMessage(message));
806
+ const automaticClaimId = automaticMessage === undefined ? undefined : pluginContinuationId(automaticMessage);
755
807
  const decision = decideDshContinuation(database, {
756
808
  dshSessionId: event.sessionId,
757
809
  cwd: event.cwd,
810
+ ...(automaticClaimId === undefined ? {} : { claimId: automaticClaimId }),
758
811
  }, runId);
759
812
  if (!decision.continue || decision.runId !== runId) {
760
813
  throw new KiokukoError('CONFLICT', decision.warning ?? 'The active Enno-Oduno run cannot be resumed by this DSH session');
@@ -969,12 +1022,18 @@ export function createDshHostAdapter(ctx, options = {}) {
969
1022
  workUnitId: binding.workUnitId ?? null,
970
1023
  });
971
1024
  if (phase !== undefined && receiptOperation !== undefined) {
1025
+ const executionAttempt = operation === 'enno_work_report'
1026
+ ? await runtime.withDatabase(database => readEnnoSnapshot(database, {
1027
+ runId: run.runId, workspace: run.workspace, orchestrationId: run.orchestrationId,
1028
+ }).workUnits.find(unit => unit.workUnit.id === binding.workUnitId)?.attemptCount ?? 0)
1029
+ : 0;
972
1030
  await runtime.withDatabase((database) => prepareTurnIntent(database, {
973
1031
  runId: run.runId,
974
1032
  dshSessionId: run.sessionId,
975
1033
  nativeTurn: run.turn,
976
1034
  phase,
977
1035
  contractRevision: binding.revision,
1036
+ executionAttempt,
978
1037
  ...(binding.workUnitId === undefined ? {} : { workUnitId: binding.workUnitId }),
979
1038
  inputDigest,
980
1039
  operation: receiptOperation,
@@ -1101,7 +1160,8 @@ export function createDshHostAdapter(ctx, options = {}) {
1101
1160
  return response.ennoOduno;
1102
1161
  };
1103
1162
  const boundaryAgents = new Map();
1104
- const boundaryEvent = (item, signal = new AbortController().signal) => ({
1163
+ const boundarySignals = new Map();
1164
+ const boundaryEvent = (item, signal = boundarySignals.get(item.sessionId) ?? new AbortController().signal) => ({
1105
1165
  agent: {
1106
1166
  id: item.agentId,
1107
1167
  sessionId: item.sessionId,
@@ -1113,6 +1173,87 @@ export function createDshHostAdapter(ctx, options = {}) {
1113
1173
  signal,
1114
1174
  });
1115
1175
  const readBoundaryState = async (item) => (runtime.withDatabase((database) => stateForRun(database, item)));
1176
+ const askForRecoveryInstruction = async (input) => {
1177
+ const agent = input.item.nativeAgent ?? agents?.get(input.item.agentId);
1178
+ const signal = boundarySignals.get(input.item.sessionId) ?? new AbortController().signal;
1179
+ if (userQuestions === undefined || agent === undefined)
1180
+ return undefined;
1181
+ try {
1182
+ const result = await abortable(userQuestions.ask({
1183
+ questions: [{
1184
+ id: input.questionId,
1185
+ header: 'Kiokuko stopped',
1186
+ question: input.title,
1187
+ detail: input.detail,
1188
+ }],
1189
+ agent,
1190
+ signal,
1191
+ }), signal);
1192
+ signal.throwIfAborted();
1193
+ const answer = result.answers[0];
1194
+ if (answer === undefined || answer.id !== input.questionId)
1195
+ return undefined;
1196
+ const value = answer.custom?.trim() || answer.selected[0]?.trim();
1197
+ return value === undefined || value.length === 0 ? undefined : boundedUtf8Text(value, 8 * 1024);
1198
+ }
1199
+ catch {
1200
+ signal.throwIfAborted();
1201
+ // A broken/dismissed question surface must never become another retry
1202
+ // loop. The durable waiting_user state remains the recovery boundary.
1203
+ return undefined;
1204
+ }
1205
+ };
1206
+ const loopRecoveryDetail = (snapshot, state, reason) => {
1207
+ const workUnitId = state.directive?.workUnit?.id ?? null;
1208
+ const workUnit = workUnitId === null
1209
+ ? null
1210
+ : snapshot.workUnits.find((candidate) => candidate.workUnit.id === workUnitId) ?? null;
1211
+ const latestVerifier = snapshot.finalEvidence.at(-1);
1212
+ return [
1213
+ `Host status: phase=${snapshot.status}; nextAction=${state.nextAction}; role=${state.currentRole ?? 'none'}.`,
1214
+ `Revision=${snapshot.revision}; mutationRevision=${snapshot.mutationRevision}; attempts=${snapshot.attempts}/${snapshot.contract.maxAttempts}.`,
1215
+ workUnit === null
1216
+ ? 'WorkUnit: none.'
1217
+ : `WorkUnit ${workUnit.workUnit.id}: ${workUnit.workUnit.objective} (status=${workUnit.status}, attempts=${workUnit.attemptCount}).`,
1218
+ latestVerifier === undefined
1219
+ ? 'Latest verifier: none.'
1220
+ : `Latest verifier ${latestVerifier.verifier.id}: ${latestVerifier.status}.`,
1221
+ reason,
1222
+ 'Enter the actual current handling status and the concrete instruction to execute next. If work should stop, say so explicitly.',
1223
+ ].join('\n');
1224
+ };
1225
+ const guardBoundaryEffect = async (item, job) => {
1226
+ const guarded = await runtime.withDatabase((database) => withImmediateTransaction(database, () => {
1227
+ const snapshot = readEnnoSnapshot(database, {
1228
+ runId: item.runId, workspace: item.workspace, orchestrationId: item.orchestrationId,
1229
+ });
1230
+ const state = stateForSnapshot(snapshot);
1231
+ const claim = claimBoundaryEffectInTransaction(database, job, ennoInstructionDigest(snapshot, state.directive), options.now?.() ?? new Date().toISOString());
1232
+ return { snapshot, state, claim };
1233
+ }));
1234
+ if (guarded.claim.decision === 'deliver')
1235
+ return true;
1236
+ const answer = await askForRecoveryInstruction({
1237
+ item,
1238
+ questionId: `effect-${job.jobId.slice(0, 16)}`,
1239
+ title: 'Kiokuko stopped before a fourth stateful boundary operation without progress.',
1240
+ detail: loopRecoveryDetail(guarded.snapshot, guarded.state, `${job.kind} completed or was re-entered three times without authoritative Enno progress.`),
1241
+ });
1242
+ if (answer === undefined) {
1243
+ await sessionMirror.markWaitingUser(item.sessionId);
1244
+ return false;
1245
+ }
1246
+ await runtime.withDatabase((database) => withImmediateTransaction(database, () => {
1247
+ resetBoundaryEffectGuardInTransaction(database, job, options.now?.() ?? new Date().toISOString());
1248
+ resetLoopGuardForUserInTransaction(database, {
1249
+ runId: item.runId,
1250
+ dshSessionId: item.sessionId,
1251
+ resolution: 'manual_user',
1252
+ ...(options.now === undefined ? {} : { now: options.now() }),
1253
+ });
1254
+ }));
1255
+ return true;
1256
+ };
1116
1257
  const confirmBoundary = async (item, state) => {
1117
1258
  const confirmation = state.directive?.userFacingConfirmation;
1118
1259
  if (confirmation === undefined || state.contractRevision === null)
@@ -1128,6 +1269,7 @@ export function createDshHostAdapter(ctx, options = {}) {
1128
1269
  }).revision);
1129
1270
  },
1130
1271
  submit: async (answer) => {
1272
+ event.signal.throwIfAborted();
1131
1273
  response = await runtime.withDatabase((database) => answerEnno(database, {
1132
1274
  runId: item.runId,
1133
1275
  workspace: item.workspace,
@@ -1180,6 +1322,7 @@ export function createDshHostAdapter(ctx, options = {}) {
1180
1322
  ...(advisoryEvidence === undefined ? {} : { advisoryEvidence }),
1181
1323
  runtime,
1182
1324
  });
1325
+ event.signal.throwIfAborted();
1183
1326
  for (const message of messages) {
1184
1327
  agent.inject({ id: randomUUID(), role: 'user', content: [{ type: 'text', text: message.content }], source: { kind: 'plugin', plugin: 'kiokuko-dsh', form: 'instructions' } });
1185
1328
  }
@@ -1212,6 +1355,23 @@ export function createDshHostAdapter(ctx, options = {}) {
1212
1355
  return readBoundaryState(item);
1213
1356
  },
1214
1357
  validateBoundary: async ({ event }) => { await assertTurnBoundary(event); },
1358
+ requestLoopRecovery: async ({ event, state, automaticCount }) => {
1359
+ const item = currentForAgentEvent(event.agent.id, event.agent.sessionId, event.turn, event.agent.nativeSession, event.agent.nativeAgent);
1360
+ if (item === undefined)
1361
+ return undefined;
1362
+ const snapshot = await runtime.withDatabase((database) => readEnnoSnapshot(database, {
1363
+ runId: item.runId, workspace: item.workspace, orchestrationId: item.orchestrationId,
1364
+ }));
1365
+ const answer = await askForRecoveryInstruction({
1366
+ item,
1367
+ questionId: `legacy-loop-${item.runId.slice(0, 12)}`,
1368
+ title: 'Kiokuko stopped before a fourth identical automatic continuation.',
1369
+ detail: loopRecoveryDetail(snapshot, state, `The legacy turn controller continued the same instruction ${automaticCount} times without authoritative Enno progress.`),
1370
+ });
1371
+ if (answer === undefined)
1372
+ await sessionMirror.markWaitingUser(item.sessionId);
1373
+ return answer;
1374
+ },
1215
1375
  confirmUser: async ({ event, state }) => {
1216
1376
  const item = currentForAgentEvent(event.agent.id, event.agent.sessionId, event.turn, event.agent.nativeSession, event.agent.nativeAgent);
1217
1377
  if (item === undefined)
@@ -1244,12 +1404,49 @@ export function createDshHostAdapter(ctx, options = {}) {
1244
1404
  }
1245
1405
  boundaryAgents.set(sessionId, nativeAgent);
1246
1406
  },
1247
- process: async (job) => {
1407
+ process: async (job, signal) => {
1408
+ boundarySignals.set(job.dshSessionId, signal);
1248
1409
  const item = currentSession(job.dshSessionId);
1249
1410
  if (item === undefined || item.closed || item.runId !== job.runId || item.turn < job.nativeTurn) {
1250
1411
  throw new Error('kiokuko-dsh boundary job has no exact live run binding');
1251
1412
  }
1252
- if (job.kind.startsWith('retry_') || job.kind === 'ask_akinator') {
1413
+ if ((job.kind === 'confirmation' || job.kind === 'final_verification' || job.kind === 'advisory')
1414
+ && !await guardBoundaryEffect(item, job)) {
1415
+ return { kind: 'waiting_user' };
1416
+ }
1417
+ if (job.kind.startsWith('retry_')) {
1418
+ return { kind: 'completed', nextKind: 'delivery' };
1419
+ }
1420
+ if (job.kind === 'ask_akinator') {
1421
+ const snapshot = await runtime.withDatabase((database) => readEnnoSnapshot(database, {
1422
+ runId: item.runId, workspace: item.workspace, orchestrationId: item.orchestrationId,
1423
+ }));
1424
+ const state = stateForSnapshot(snapshot);
1425
+ const pending = await runtime.withDatabase((database) => readPendingOutbox(database, item.sessionId)
1426
+ .find((candidate) => candidate.receiptId === job.receiptId));
1427
+ const validationFact = boundedMessageText(pending?.message);
1428
+ const answer = await askForRecoveryInstruction({
1429
+ item,
1430
+ questionId: `validation-${job.receiptId.slice(0, 16)}`,
1431
+ title: 'Kiokuko validation repeatedly failed and needs your instruction.',
1432
+ detail: loopRecoveryDetail(snapshot, state, validationFact ?? 'The same validation constraint was rejected repeatedly.'),
1433
+ });
1434
+ if (answer === undefined) {
1435
+ await sessionMirror.markWaitingUser(item.sessionId);
1436
+ return { kind: 'waiting_user' };
1437
+ }
1438
+ await runtime.withDatabase((database) => withImmediateTransaction(database, () => {
1439
+ resetLoopGuardForUserInTransaction(database, {
1440
+ runId: item.runId,
1441
+ dshSessionId: item.sessionId,
1442
+ resolution: 'manual_user',
1443
+ ...(options.now === undefined ? {} : { now: options.now() }),
1444
+ });
1445
+ const outbox = readPendingOutbox(database, item.sessionId).find((candidate) => candidate.receiptId === job.receiptId);
1446
+ if (outbox !== undefined) {
1447
+ replacePendingOutboxMessageInTransaction(database, job.receiptId, recoveryMessage(outbox.continuationId, answer), options.now?.() ?? new Date().toISOString());
1448
+ }
1449
+ }));
1253
1450
  return { kind: 'completed', nextKind: 'delivery' };
1254
1451
  }
1255
1452
  if (job.kind === 'classify_boundary') {
@@ -1288,7 +1485,8 @@ export function createDshHostAdapter(ctx, options = {}) {
1288
1485
  const directive = state.directive?.advisoryRound;
1289
1486
  if (directive === undefined)
1290
1487
  return { kind: 'completed', nextKind: 'classify_boundary' };
1291
- const result = await advisoryRunner.run({ directive, signal: new AbortController().signal });
1488
+ const result = await advisoryRunner.run({ directive, signal });
1489
+ signal.throwIfAborted();
1292
1490
  await submitAdvisory(result, { event: boundaryEvent(item), state });
1293
1491
  return { kind: 'completed', nextKind: 'classify_boundary' };
1294
1492
  }
@@ -1316,6 +1514,60 @@ export function createDshHostAdapter(ctx, options = {}) {
1316
1514
  }
1317
1515
  catch { /* non-vetoing cache */ }
1318
1516
  },
1517
+ beforeDelivery: async (job, outbox, signal) => {
1518
+ boundarySignals.set(job.dshSessionId, signal);
1519
+ if (isLoopRecoveryMessage(outbox.message))
1520
+ return 'deliver';
1521
+ const item = currentSession(job.dshSessionId);
1522
+ if (item === undefined || item.closed || item.runId !== job.runId)
1523
+ return 'superseded';
1524
+ const guarded = await runtime.withDatabase((database) => withImmediateTransaction(database, () => {
1525
+ const snapshot = readEnnoSnapshot(database, {
1526
+ runId: item.runId, workspace: item.workspace, orchestrationId: item.orchestrationId,
1527
+ });
1528
+ const state = stateForSnapshot(snapshot);
1529
+ const claim = claimAutomaticContinuationInTransaction(database, {
1530
+ claimId: outbox.continuationId,
1531
+ runId: item.runId,
1532
+ dshSessionId: item.sessionId,
1533
+ instructionDigest: ennoInstructionDigest(snapshot, state.directive),
1534
+ ...(options.now === undefined ? {} : { now: options.now() }),
1535
+ });
1536
+ const shouldAsk = claim.decision === 'wait_user'
1537
+ && claimLoopRecoveryQuestionInTransaction(database, claim.claimId, options.now?.() ?? new Date().toISOString());
1538
+ return { snapshot, state, claim, shouldAsk };
1539
+ }));
1540
+ if (guarded.claim.decision === 'deliver')
1541
+ return 'deliver';
1542
+ if (!guarded.shouldAsk) {
1543
+ await sessionMirror.markWaitingUser(item.sessionId);
1544
+ return 'waiting_user';
1545
+ }
1546
+ const answer = await askForRecoveryInstruction({
1547
+ item,
1548
+ questionId: `loop-${guarded.claim.claimId.slice(0, 16)}`,
1549
+ title: 'Kiokuko stopped before a fourth identical automatic continuation.',
1550
+ detail: loopRecoveryDetail(guarded.snapshot, guarded.state, [
1551
+ `The same instruction was automatically continued ${guarded.claim.ordinal - 1} times without authoritative Enno progress.`,
1552
+ boundedMessageText(outbox.message),
1553
+ ].filter((value) => value !== undefined).join('\n')),
1554
+ });
1555
+ if (answer === undefined) {
1556
+ await sessionMirror.markWaitingUser(item.sessionId);
1557
+ return 'waiting_user';
1558
+ }
1559
+ await runtime.withDatabase((database) => withImmediateTransaction(database, () => {
1560
+ resetLoopGuardForUserInTransaction(database, {
1561
+ runId: item.runId,
1562
+ dshSessionId: item.sessionId,
1563
+ resolution: 'user_answer',
1564
+ claimId: guarded.claim.claimId,
1565
+ ...(options.now === undefined ? {} : { now: options.now() }),
1566
+ });
1567
+ replacePendingOutboxMessageInTransaction(database, job.receiptId, recoveryMessage(outbox.continuationId, answer), options.now?.() ?? new Date().toISOString());
1568
+ }));
1569
+ return 'deliver';
1570
+ },
1319
1571
  dispatch: async (job, outbox) => {
1320
1572
  const item = currentSession(job.dshSessionId);
1321
1573
  const nativeAgent = (boundaryAgents.get(job.dshSessionId) ?? agents?.get(job.dshSessionId));
@@ -1324,13 +1576,81 @@ export function createDshHostAdapter(ctx, options = {}) {
1324
1576
  }
1325
1577
  nativeAgent.steer(outbox.message);
1326
1578
  },
1579
+ onWaitingUser: async (job, error, signal) => {
1580
+ boundarySignals.set(job.dshSessionId, signal);
1581
+ const item = currentSession(job.dshSessionId);
1582
+ if (item === undefined || item.closed || item.runId !== job.runId)
1583
+ return false;
1584
+ await sessionMirror.markWaitingUser(item.sessionId);
1585
+ const snapshot = await runtime.withDatabase((database) => readEnnoSnapshot(database, {
1586
+ runId: item.runId, workspace: item.workspace, orchestrationId: item.orchestrationId,
1587
+ }));
1588
+ const answer = await askForRecoveryInstruction({
1589
+ item,
1590
+ questionId: `boundary-${job.jobId.slice(0, 16)}`,
1591
+ title: 'Kiokuko stopped after three boundary-processing failures.',
1592
+ detail: loopRecoveryDetail(snapshot, stateForSnapshot(snapshot), `Last boundary error: ${(error instanceof Error ? error.message : String(error)).slice(0, 2_000)}`),
1593
+ });
1594
+ if (answer === undefined)
1595
+ return false;
1596
+ await runtime.withDatabase((database) => withImmediateTransaction(database, () => {
1597
+ resetLoopGuardForUserInTransaction(database, {
1598
+ runId: item.runId,
1599
+ dshSessionId: item.sessionId,
1600
+ resolution: 'manual_user',
1601
+ ...(options.now === undefined ? {} : { now: options.now() }),
1602
+ });
1603
+ const outbox = readPendingOutbox(database, item.sessionId).find((candidate) => candidate.receiptId === job.receiptId);
1604
+ if (outbox !== undefined) {
1605
+ replacePendingOutboxMessageInTransaction(database, job.receiptId, recoveryMessage(outbox.continuationId, answer), options.now?.() ?? new Date().toISOString());
1606
+ }
1607
+ database.prepare(`
1608
+ UPDATE dsh_boundary_jobs
1609
+ SET status = 'pending', attempt_count = 0, available_at = ?,
1610
+ last_error_code = NULL, last_error_message = NULL, updated_at = ?
1611
+ WHERE job_id = ? AND status = 'waiting_user'
1612
+ `).run(options.now?.() ?? new Date().toISOString(), options.now?.() ?? new Date().toISOString(), job.jobId);
1613
+ }));
1614
+ return true;
1615
+ },
1616
+ });
1617
+ const completionReporter = new DshCompletionReporter(runtime, async (session) => {
1618
+ if (sessions?.flush === undefined)
1619
+ throw new Error('Native report flush is unavailable');
1620
+ await sessions.flush(session);
1327
1621
  });
1622
+ const deliverCompletionReport = async (session) => {
1623
+ if (session !== undefined && typeof session.append === 'function') {
1624
+ await completionReporter.deliver(session);
1625
+ }
1626
+ };
1328
1627
  const rehydrateBoundarySession = async (nativeAgent) => {
1329
1628
  const nativeSession = nativeAgent.session ?? sessions?.get(nativeAgent.id);
1330
1629
  const sessionId = nativeSession?.id;
1331
1630
  const cwd = nativeSession?.header?.cwd;
1332
1631
  if (sessionId === undefined || cwd === undefined || typeof nativeSession?.snapshotEvents !== 'function')
1333
1632
  return;
1633
+ if (nativeAgent.status === 'running')
1634
+ return;
1635
+ await deliverCompletionReport(nativeSession);
1636
+ const terminal = await runtime.withDatabase(database => database.prepare(`
1637
+ SELECT report.run_id AS runId, report.native_turn AS nativeTurn, contract.status
1638
+ FROM dsh_completion_reports AS report JOIN ledger_runs AS run ON run.run_id = report.run_id
1639
+ JOIN enno_contracts AS contract ON contract.run_id = report.run_id
1640
+ WHERE report.dsh_session_id = ? AND report.status = 'delivered' AND run.status = 'active'
1641
+ AND contract.status IN ('completed', 'blocked', 'cancelled')
1642
+ `).all(sessionId));
1643
+ for (const restored of terminal) {
1644
+ if (currentSession(sessionId)?.runId === restored.runId)
1645
+ continue;
1646
+ const end = dshTurnBoundarySeq(nativeSession, restored.nativeTurn, 'end');
1647
+ if (end === undefined)
1648
+ continue;
1649
+ await runLifecycle.closeTurn({ runId: restored.runId,
1650
+ status: restored.status === 'completed' ? 'completed' : restored.status === 'cancelled' ? 'cancelled' : 'failed',
1651
+ sourceEndSeq: end,
1652
+ });
1653
+ }
1334
1654
  const pending = await runtime.withDatabase((database) => database.prepare(`
1335
1655
  SELECT receipt.run_id AS runId, receipt.native_turn AS nativeTurn,
1336
1656
  intake.task_text AS task
@@ -1371,6 +1691,7 @@ export function createDshHostAdapter(ctx, options = {}) {
1371
1691
  void rehydrateBoundarySession(nativeAgent).catch(() => undefined);
1372
1692
  }
1373
1693
  const resolveIdleClose = async (agentId, sessionId, nativeSession, nativeAgent) => {
1694
+ await deliverCompletionReport(nativeSession);
1374
1695
  const item = currentForAgentEvent(agentId, sessionId, undefined, nativeSession, nativeAgent);
1375
1696
  if (item === undefined || item.closed)
1376
1697
  return undefined;
@@ -1385,9 +1706,9 @@ export function createDshHostAdapter(ctx, options = {}) {
1385
1706
  if (item.prepared.intake.profile.taskType === 'chat')
1386
1707
  return undefined;
1387
1708
  if (state.status === 'completed' || state.nextAction === 'complete') {
1388
- return item.failed
1389
- ? { runId: item.runId, status: 'failed' }
1390
- : { runId: item.runId, status: 'completed', terminalTurn: item.turn };
1709
+ // A provider failure while wording the final response cannot undo
1710
+ // already verified completion. The durable host report covers it.
1711
+ return { runId: item.runId, status: 'completed', terminalTurn: item.turn };
1391
1712
  }
1392
1713
  // Idle means the driver has no active work, not that Enno reached a
1393
1714
  // terminal state. Keep only genuinely resumable active states open.
@@ -1403,14 +1724,18 @@ export function createDshHostAdapter(ctx, options = {}) {
1403
1724
  const item = currentSession(sessionId);
1404
1725
  if (item === undefined || item.closed || item.nativeSession !== nativeSession)
1405
1726
  return undefined;
1727
+ const state = await runtime.withDatabase((database) => stateForRun(database, item));
1728
+ if (state.status === 'completed') {
1729
+ await deliverCompletionReport(nativeSession);
1730
+ return { runId: item.runId, status: 'completed', terminalTurn: item.turn };
1731
+ }
1406
1732
  if (item.failed)
1407
1733
  return { runId: item.runId, status: 'failed' };
1408
- const state = await runtime.withDatabase((database) => stateForRun(database, item));
1409
1734
  if (state.status === 'cancelled')
1410
1735
  return { runId: item.runId, status: 'cancelled' };
1411
1736
  if (state.status === 'blocked' || state.nextAction === 'report_blocker')
1412
1737
  return { runId: item.runId, status: 'failed' };
1413
- if (item.prepared.intake.profile.taskType === 'chat' || state.status === 'completed' || state.nextAction === 'complete') {
1738
+ if (item.prepared.intake.profile.taskType === 'chat' || state.nextAction === 'complete') {
1414
1739
  return { runId: item.runId, status: 'completed', terminalTurn: item.turn };
1415
1740
  }
1416
1741
  return { runId: item.runId, status: 'cancelled' };
@@ -1578,6 +1903,27 @@ export function createDshHostAdapter(ctx, options = {}) {
1578
1903
  }
1579
1904
  if (!turnEndedWithError || (!durableRecoverable && !locallyRecoverable))
1580
1905
  inMemoryClaims.delete(claimKey);
1906
+ if (reason?.kind === 'completed' && item.prepared.ennoOduno.applicable) {
1907
+ await runtime.withDatabase(database => {
1908
+ const state = stateForRun(database, item);
1909
+ if (state.nextAction === 'complete' || state.nextAction === 'report_blocker'
1910
+ || state.status === 'cancelled' || state.contractRevision === null)
1911
+ return;
1912
+ const phase = state.nextAction === 'submit_ideal' ? 'ideal'
1913
+ : state.nextAction === 'submit_plan' || state.nextAction === 'ask_user_confirmation' ? 'planning'
1914
+ : state.nextAction === 'execute_work_unit' ? 'work_unit'
1915
+ : state.nextAction === 'submit_meditation' ? 'meditation' : 'final_review';
1916
+ const operation = phase === 'ideal' ? 'ideal_submit' : phase === 'planning' ? 'plan_submit'
1917
+ : phase === 'work_unit' ? 'work_report' : phase === 'meditation' ? 'meditation_submit' : 'finish';
1918
+ enqueueUnsubmittedTurn(database, {
1919
+ runId: item.runId, dshSessionId: item.sessionId, nativeTurn: item.turn,
1920
+ phase, operation, contractRevision: state.contractRevision,
1921
+ ...(state.directive?.workUnit?.id === undefined ? {} : { workUnitId: state.directive.workUnit.id }),
1922
+ inputDigest: canonicalContentHash({ nextAction: state.nextAction, source: 'unsubmitted_turn' }),
1923
+ idempotencyKey: `dsh-unsubmitted:${item.turn}`, nextAction: state.nextAction,
1924
+ });
1925
+ });
1926
+ }
1581
1927
  boundaryWorker.kick(item.sessionId, item.nativeAgent);
1582
1928
  })().catch(() => {
1583
1929
  // Observe-only DSH listeners must never veto the native event.
@@ -1680,19 +2026,18 @@ export function createDshHostAdapter(ctx, options = {}) {
1680
2026
  for (const item of remainingRuns) {
1681
2027
  try {
1682
2028
  let status;
1683
- if (item.failed)
1684
- status = 'failed';
1685
- else if (item.prepared.intake.profile.taskType === 'chat')
1686
- status = 'cancelled';
2029
+ if (item.prepared.intake.profile.taskType === 'chat')
2030
+ status = item.failed ? 'failed' : 'cancelled';
1687
2031
  else {
1688
2032
  const state = await runtime.withDatabase((database) => stateForRun(database, item));
1689
2033
  status = state.status === 'completed' || state.nextAction === 'complete'
1690
2034
  ? 'completed'
1691
- : state.status === 'blocked' || state.nextAction === 'report_blocker'
2035
+ : item.failed || state.status === 'blocked' || state.nextAction === 'report_blocker'
1692
2036
  ? 'failed'
1693
2037
  : 'cancelled';
1694
2038
  }
1695
2039
  if (status === 'completed') {
2040
+ await deliverCompletionReport(item.nativeSession);
1696
2041
  if (item.nativeSession === undefined || sessions?.flush === undefined) {
1697
2042
  throw new KiokukoError('CONFLICT', 'Completed DSH run requires its exact native session checkpoint');
1698
2043
  }
@@ -1741,6 +2086,7 @@ export function createDshHostAdapter(ctx, options = {}) {
1741
2086
  activeModeRequests.clear();
1742
2087
  advisoryRounds.clear();
1743
2088
  boundaryAgents.clear();
2089
+ boundarySignals.clear();
1744
2090
  resumedTurns.clear();
1745
2091
  resumedLeases.clear();
1746
2092
  inMemoryClaims.clear();