Skip to content

Commit c97801c

Browse files
authored
fix(ai): expose REM extractor failure terminality (#13974) (#13975)
* fix(ai): expose REM extractor failure terminality (#13974) * fix(ai): satisfy AiConfig SSOT guard (#13974)
1 parent 4692d18 commit c97801c

4 files changed

Lines changed: 271 additions & 90 deletions

File tree

ai/daemons/orchestrator/services/DreamService.mjs

Lines changed: 47 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -41,21 +41,29 @@ import {bytesToTokens} from '../../../services/memory-core/helpers/consumerFrict
4141
const __filename = fileURLToPath(import.meta.url);
4242
const __dirname = path.dirname(__filename);
4343

44-
// Bounds the re-serve ONLY for the DETERMINISTICALLY-terminal failure: `skip-over-band` (payload over the
45-
// model's safe band) is a reliable size check — that session genuinely cannot be processed at this cadence
46-
// until a deep-digest lane exists, so deferring it after the `maxDigestAttempts` config leaf is a safe bleed-stop.
47-
// `under-band-choke` (a bare-null return UNDER the band) and a transient ingestion-failure are NOT proven
48-
// terminal — the extractor returns the same null for choke / schema-failure / timeout alike, so treating an
49-
// under-band null as un-digestible is a guess. They stay `undigested` (attempt-tracked, re-served) until
50-
// typed extractor failure semantics exist; deferring on a guessed-terminal null would risk silently
51-
// dropping a digestible session.
52-
const PERMANENT_DEFER_REASONS = new Set(['skip-over-band']);
53-
5444
function estimatePayloadTokens(payload) {
5545
const text = payload === undefined || payload === null ? '' : String(payload);
5646
return bytesToTokens(Buffer.byteLength(text, 'utf8'));
5747
}
5848

49+
function isTriVectorFailureDescriptor(value) {
50+
return value?.ok === false;
51+
}
52+
53+
function getTriVectorFailureAttempts(failure) {
54+
return Number.isFinite(failure?.evidence?.attempts) ? failure.evidence.attempts : 1;
55+
}
56+
57+
function getTriVectorFailureKind(failure) {
58+
return failure?.deferReason || failure?.frictionSymptom || 'typed-failure';
59+
}
60+
61+
function getTriVectorFailureMessage(failure) {
62+
return failure?.evidence?.note ||
63+
failure?.evidence?.errorMessage ||
64+
`tri-vector extraction failed (${getTriVectorFailureKind(failure)})`;
65+
}
66+
5967
function toErrorMessage(error) {
6068
return error && error.message !== undefined ? String(error.message) : String(error);
6169
}
@@ -457,9 +465,9 @@ class DreamService extends Base {
457465
}
458466

459467
const startTime = Date.now();
460-
let success;
468+
let extractionResult;
461469
try {
462-
success = await SemanticGraphExtractor.executeTriVectorExtraction(session);
470+
extractionResult = await SemanticGraphExtractor.executeTriVectorExtraction(session);
463471
} catch (e) {
464472
sessionState.triVector = {
465473
status : 'failed',
@@ -476,16 +484,24 @@ class DreamService extends Base {
476484
}
477485
const triVectorTime = ((Date.now() - startTime) / 1000).toFixed(1);
478486
logger.info(`[DreamService] -> Tri-Vector Synthesis took: ${triVectorTime}s`);
487+
const extractionFailure = isTriVectorFailureDescriptor(extractionResult) ? extractionResult : null;
488+
const success = extractionResult && !extractionFailure;
479489
sessionState.triVector = {
480490
status : success ? 'completed' : 'failed',
481-
attempts : 1,
482-
errorKind: success ? undefined : 'null-result'
491+
attempts : extractionFailure ? getTriVectorFailureAttempts(extractionFailure) : 1,
492+
errorKind: success ? undefined : (extractionFailure ? getTriVectorFailureKind(extractionFailure) : 'null-result')
483493
};
494+
if (extractionFailure) {
495+
sessionState.triVector.deferReason = extractionFailure.deferReason;
496+
sessionState.triVector.frictionSymptom = extractionFailure.frictionSymptom;
497+
sessionState.triVector.terminalForCadence = extractionFailure.terminalForCadence === true;
498+
}
484499
if (!success) {
485-
sessionState.failureReasons.push('tri-vector extraction returned null');
500+
sessionState.failureReasons.push(extractionFailure ? getTriVectorFailureMessage(extractionFailure) : 'tri-vector extraction returned null');
486501
}
487502
perPhaseStates.push(finishPhase('triVector', startTime, success ? 'completed' : 'failed', {
488-
sessionId: session.meta.sessionId
503+
sessionId : session.meta.sessionId,
504+
deferReason: extractionFailure?.deferReason
489505
}));
490506

491507
const topoStart = Date.now();
@@ -530,7 +546,7 @@ class DreamService extends Base {
530546

531547
const capStart = Date.now();
532548
try {
533-
await this.inferTestGapsFromSession(success);
549+
await this.inferTestGapsFromSession(success ? extractionResult : null);
534550
} catch (e) {
535551
sessionState.gapSession = {
536552
status : 'failed',
@@ -561,33 +577,28 @@ class DreamService extends Base {
561577
sessionState.graphDigestedFlag = true;
562578
logger.info(`[DreamService] Session ${session.meta.sessionId} marked as graphDigested in Memory Core.`);
563579
} else {
564-
// Digest failed (extractor returned null OR memory-ingestion errors). Bound the
565-
// re-serve: count the attempt, and once it reaches `maxDigestAttempts` mark the
566-
// session `deferred` so findUndigestedSessions stops re-serving it every cycle —
567-
// the chronic local-model load bleed. `deferReason` records WHY for the honest
568-
// consolidation-gap surface + a future deep-digest lane. Back-compat: graphDigested
569-
// stays unset, so a consumer that does not yet read digestState still sees an
570-
// un-digested (never a falsely-digested) session.
571-
const digestAttempts = (Number(session.meta.digestAttempts) || 0) + 1;
572-
const safeBandTokens = aiConfig.localModels?.chat?.safeProcessingLimitTokens;
573-
const deferReason = ingestErrors > 0
580+
// Digest failed (typed extractor failure OR memory-ingestion errors). Bound the
581+
// re-serve only when the extractor supplies cadence-terminal evidence; ingestion
582+
// errors and legacy bare-null returns stay retryable so a storage/transient failure
583+
// never removes a digestible session from the steady cadence.
584+
const digestAttempts = (Number(session.meta.digestAttempts) || 0) + 1;
585+
const maxDigestAttempts = readRequiredNumberLeaf('maxDigestAttempts');
586+
const terminalForCadence = ingestErrors === 0 && extractionFailure?.terminalForCadence === true;
587+
const deferReason = ingestErrors > 0
574588
? 'ingestion-failure'
575-
: (safeBandTokens > 0 && sessionState.payloadSizeTokens > safeBandTokens ? 'skip-over-band' : 'under-band-choke');
576-
// Bound the re-serve ONLY for the deterministically-terminal reason (skip-over-band);
577-
// under-band-choke + transient ingestion-failure keep retrying (attempt-tracked) so a
578-
// digestible session is never silently dropped on a guessed-terminal null.
579-
const maxDigestAttempts = readRequiredNumberLeaf('maxDigestAttempts');
580-
const digestState = PERMANENT_DEFER_REASONS.has(deferReason) && digestAttempts >= maxDigestAttempts
589+
: (extractionFailure?.deferReason || 'schema-failure');
590+
const digestState = terminalForCadence && digestAttempts >= maxDigestAttempts
581591
? 'deferred'
582592
: 'undigested';
583593

584594
await this.sessionsCollection.update({
585595
ids : [session.id],
586596
metadatas: [{ ...session.meta, digestState, digestAttempts, deferReason }]
587597
});
588-
sessionState.digestState = digestState;
589-
sessionState.deferReason = deferReason;
590-
sessionState.digestAttempts = digestAttempts;
598+
sessionState.digestState = digestState;
599+
sessionState.deferReason = deferReason;
600+
sessionState.digestAttempts = digestAttempts;
601+
sessionState.terminalForCadence = terminalForCadence;
591602

592603
if (digestState === 'deferred') {
593604
logger.warn(`[DreamService] Session ${session.meta.sessionId} marked 'deferred' after ${digestAttempts} failed digest attempt(s) (reason: ${deferReason}); excluded from the steady REM cadence to stop the re-serve bleed.`);

0 commit comments

Comments
 (0)