create-principles-disciple 1.123.0 → 1.124.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.
Files changed (58) hide show
  1. package/core/dist/runtime-v2/__tests__/pain-signal-bridge-execute-pending.test.d.ts +2 -0
  2. package/core/dist/runtime-v2/__tests__/pain-signal-bridge-execute-pending.test.d.ts.map +1 -0
  3. package/core/dist/runtime-v2/__tests__/pain-signal-bridge-execute-pending.test.js +106 -0
  4. package/core/dist/runtime-v2/__tests__/pain-signal-bridge-execute-pending.test.js.map +1 -0
  5. package/core/dist/runtime-v2/feature-flags/feature-flag-lifecycle.js +2 -2
  6. package/core/dist/runtime-v2/feature-flags/feature-flag-lifecycle.js.map +1 -1
  7. package/core/dist/runtime-v2/index.d.ts +2 -2
  8. package/core/dist/runtime-v2/index.d.ts.map +1 -1
  9. package/core/dist/runtime-v2/index.js +2 -2
  10. package/core/dist/runtime-v2/index.js.map +1 -1
  11. package/core/dist/runtime-v2/internalization/internalization-task-guards.d.ts +5 -8
  12. package/core/dist/runtime-v2/internalization/internalization-task-guards.d.ts.map +1 -1
  13. package/core/dist/runtime-v2/internalization/internalization-task-guards.js +11 -12
  14. package/core/dist/runtime-v2/internalization/internalization-task-guards.js.map +1 -1
  15. package/core/dist/runtime-v2/pain-signal-bridge.d.ts +40 -0
  16. package/core/dist/runtime-v2/pain-signal-bridge.d.ts.map +1 -1
  17. package/core/dist/runtime-v2/pain-signal-bridge.js +95 -0
  18. package/core/dist/runtime-v2/pain-signal-bridge.js.map +1 -1
  19. package/core/dist/runtime-v2/pain-signal-runtime-factory.d.ts +10 -4
  20. package/core/dist/runtime-v2/pain-signal-runtime-factory.d.ts.map +1 -1
  21. package/core/dist/runtime-v2/pain-signal-runtime-factory.js +86 -51
  22. package/core/dist/runtime-v2/pain-signal-runtime-factory.js.map +1 -1
  23. package/dist/installer.d.ts.map +1 -1
  24. package/dist/installer.js +65 -4
  25. package/dist/installer.js.map +1 -1
  26. package/host-runtime/dist/governance-observation-store.d.ts +17 -0
  27. package/host-runtime/dist/governance-observation-store.js +38 -0
  28. package/host-runtime/dist/governance-signal-admission.js +55 -43
  29. package/host-runtime/dist/index.d.ts +3 -0
  30. package/host-runtime/dist/index.js +7 -0
  31. package/host-runtime/dist/internalization-consumer-cycle.d.ts +43 -0
  32. package/host-runtime/dist/internalization-consumer-cycle.js +468 -0
  33. package/host-runtime/dist/internalization-consumer-governance.d.ts +39 -0
  34. package/host-runtime/dist/internalization-consumer-governance.js +152 -0
  35. package/{plugin/dist/service/workspace-telemetry-sink.d.ts → host-runtime/dist/workspace-telemetry-emitter.d.ts} +5 -4
  36. package/host-runtime/dist/workspace-telemetry-emitter.js +74 -0
  37. package/install-layout/dist/index.d.ts +10 -0
  38. package/install-layout/dist/index.js +34 -1
  39. package/install-layout/dist/index.test.js +36 -1
  40. package/package.json +2 -2
  41. package/pd-cli/dist/commands/codex-ingest-catchup.d.ts +20 -0
  42. package/pd-cli/dist/commands/codex-ingest-catchup.d.ts.map +1 -0
  43. package/pd-cli/dist/commands/codex-ingest-catchup.js +95 -0
  44. package/pd-cli/dist/commands/codex-ingest-catchup.js.map +1 -0
  45. package/pd-cli/dist/commands/codex-worker.d.ts +20 -0
  46. package/pd-cli/dist/commands/codex-worker.d.ts.map +1 -0
  47. package/pd-cli/dist/commands/codex-worker.js +156 -0
  48. package/pd-cli/dist/commands/codex-worker.js.map +1 -0
  49. package/pd-cli/dist/index.js +47 -0
  50. package/pd-cli/dist/index.js.map +1 -1
  51. package/pd-cli/package.json +1 -1
  52. package/plugin/dist/bundle.js +432 -432
  53. package/plugin/dist/governance-audit.js +106 -106
  54. package/plugin/dist/rulehost-evidence.js +111 -111
  55. package/plugin/dist/service/internalization-auto-consumer-service.d.ts +11 -0
  56. package/plugin/openclaw.plugin.json +1 -1
  57. package/plugin/package.json +1 -1
  58. package/plugin/dist/service/auto-consumer-governance-wiring.d.ts +0 -34
@@ -34,7 +34,7 @@
34
34
  import fs from 'node:fs';
35
35
  import path from 'node:path';
36
36
  import Database from 'better-sqlite3';
37
- import { collectSync, CORRECTION_SEED_KEYWORDS, buildToolFailureObservation, evaluateTriage, evaluateTriggerController, resolveSourceKind, PainToPrincipleService, PrincipleTreeLedgerAdapter, RuntimeStateManager, createDiagnosticianTaskId, sanitizeString, MAX_EVIDENCE_VALUE_CHARS, } from '@principles/core/runtime-v2';
37
+ import { collectSync, CORRECTION_SEED_KEYWORDS, buildToolFailureObservation, evaluateTriage, evaluateTriggerController, resolveSourceKind, PainToPrincipleService, PrincipleTreeLedgerAdapter, RuntimeStateManager, createDiagnosticianTaskId, disposePainSignalBridgesForWorkspace, sanitizeString, MAX_EVIDENCE_VALUE_CHARS, } from '@principles/core/runtime-v2';
38
38
  import { deriveProductionCorrectionPainIdentity, deriveProductionToolPainIdentity, hasProductionPainSchema, PRODUCTION_WRITE_TOOLS, } from './production-pain-evidence.js';
39
39
  import { promoteGovernanceEvidence, ensureGovernanceSchema } from './governance-observation-store.js';
40
40
  import { loadPdConfigForPlugin } from './pd-config.js';
@@ -632,51 +632,63 @@ export async function ensureGovernanceDiagnosticianTask(input) {
632
632
  const taskId = createDiagnosticianTaskId(input.canonicalPainId);
633
633
  // Cross-store crash recovery (SPEC §13): a task may already exist from a
634
634
  // crashed prior attempt (crash-after-create, before the link write).
635
+ // PRI-624: the state manager MUST be closed on every exit — the Slice C
636
+ // worker calls this seam every cycle, and a leaked handle would pin the
637
+ // workspace state.db for the life of the worker process.
635
638
  const stateManager = new RuntimeStateManager({ workspaceDir });
636
- await stateManager.initialize();
637
- const existing = await stateManager.getTask(taskId);
638
- if (existing === null) {
639
- const stateDir = path.join(workspaceDir, '.state');
640
- const config = loadPdConfigForPlugin(workspaceDir);
641
- if (!config.ok) {
642
- return { ok: false, reason: `pd_config_invalid:${config.errors[0]?.reason ?? 'unknown'}`, nextAction: config.errors[0]?.nextAction ?? 'Repair .pd/config.yaml and run reconciliation.' };
643
- }
644
- const service = new PainToPrincipleService({
645
- workspaceDir,
646
- stateDir,
647
- ledgerAdapter: new PrincipleTreeLedgerAdapter({ stateDir }),
648
- owner: 'codex-governance',
649
- asyncMode: true,
650
- effectiveConfig: config.effective,
651
- getEnvVar: (name) => process.env[name],
652
- });
653
- const result = await service.recordPain({
654
- painId: input.canonicalPainId,
655
- painType: payloadParsed.painType,
656
- source: payloadParsed.source,
657
- reason: payloadParsed.reason,
658
- score: payloadParsed.score,
659
- sessionId: payloadParsed.sessionId,
660
- provenance: 'host_context_bound',
661
- hostKind: 'codex',
662
- evidence: [...payloadParsed.evidence],
663
- recordObservability: true,
664
- });
665
- if (result.status === 'failed') {
666
- return { ok: false, reason: `task_submit_failed:${(result.message ?? result.failureCategory ?? 'unknown').slice(0, 160)}`, nextAction: 'inspect the diagnostician runtime profile; reconciliation retries task creation without losing the admitted pain' };
639
+ try {
640
+ await stateManager.initialize();
641
+ const existing = await stateManager.getTask(taskId);
642
+ if (existing === null) {
643
+ const stateDir = path.join(workspaceDir, '.state');
644
+ const config = loadPdConfigForPlugin(workspaceDir);
645
+ if (!config.ok) {
646
+ return { ok: false, reason: `pd_config_invalid:${config.errors[0]?.reason ?? 'unknown'}`, nextAction: config.errors[0]?.nextAction ?? 'Repair .pd/config.yaml and run reconciliation.' };
647
+ }
648
+ const service = new PainToPrincipleService({
649
+ workspaceDir,
650
+ stateDir,
651
+ ledgerAdapter: new PrincipleTreeLedgerAdapter({ stateDir }),
652
+ owner: 'codex-governance',
653
+ asyncMode: true,
654
+ effectiveConfig: config.effective,
655
+ getEnvVar: (name) => process.env[name],
656
+ });
657
+ const result = await service.recordPain({
658
+ painId: input.canonicalPainId,
659
+ painType: payloadParsed.painType,
660
+ source: payloadParsed.source,
661
+ reason: payloadParsed.reason,
662
+ score: payloadParsed.score,
663
+ sessionId: payloadParsed.sessionId,
664
+ provenance: 'host_context_bound',
665
+ hostKind: 'codex',
666
+ evidence: [...payloadParsed.evidence],
667
+ recordObservability: true,
668
+ });
669
+ // recordPain (async) leaves a cached bridge holding open SQLite
670
+ // handles; this seam runs per reconciliation cycle under the Slice C
671
+ // worker, so release it immediately (hook processes exit anyway).
672
+ await disposePainSignalBridgesForWorkspace(workspaceDir).catch(() => undefined);
673
+ if (result.status === 'failed') {
674
+ return { ok: false, reason: `task_submit_failed:${(result.message ?? result.failureCategory ?? 'unknown').slice(0, 160)}`, nextAction: 'inspect the diagnostician runtime profile; reconciliation retries task creation without losing the admitted pain' };
675
+ }
667
676
  }
677
+ db.prepare('UPDATE governance_signal_admissions SET diagnostician_task_id = ?, updated_at = ? WHERE logical_observation_key = ?')
678
+ .run(taskId, new Date().toISOString(), input.logicalObservationKey);
679
+ return {
680
+ ok: true,
681
+ taskId,
682
+ created: existing === null,
683
+ duplicate: existing !== null,
684
+ // The link was just written because the task already existed → a real
685
+ // Case B repair (not a no-op on an already-linked marker).
686
+ linkRepaired: existing !== null,
687
+ };
688
+ }
689
+ finally {
690
+ await stateManager.close().catch(() => undefined);
668
691
  }
669
- db.prepare('UPDATE governance_signal_admissions SET diagnostician_task_id = ?, updated_at = ? WHERE logical_observation_key = ?')
670
- .run(taskId, new Date().toISOString(), input.logicalObservationKey);
671
- return {
672
- ok: true,
673
- taskId,
674
- created: existing === null,
675
- duplicate: existing !== null,
676
- // The link was just written because the task already existed → a real
677
- // Case B repair (not a no-op on an already-linked marker).
678
- linkRepaired: existing !== null,
679
- };
680
692
  }
681
693
  catch (error) {
682
694
  const detail = error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200);
@@ -11,6 +11,9 @@ export * from './production-pain-evidence.js';
11
11
  export * from './governance-observation-store.js';
12
12
  export * from './governance-signal-admission.js';
13
13
  export * from './host-liveness-contract.js';
14
+ export * from './internalization-consumer-governance.js';
15
+ export * from './internalization-consumer-cycle.js';
16
+ export * from './workspace-telemetry-emitter.js';
14
17
  export * from './product-telemetry/consent-store.js';
15
18
  export * from './product-telemetry/eligibility.js';
16
19
  export * from './product-telemetry/exporter.js';
@@ -10,6 +10,13 @@ export * from './production-pain-evidence.js';
10
10
  export * from './governance-observation-store.js';
11
11
  export * from './governance-signal-admission.js';
12
12
  export * from './host-liveness-contract.js';
13
+ // PRI-624 Slice C: shared internalization consumer execution (OpenClaw
14
+ // auto-consumer + Companion workspace worker call the same cycle).
15
+ export * from './internalization-consumer-governance.js';
16
+ export * from './internalization-consumer-cycle.js';
17
+ // PRI-634 A3: workspace-scoped critical telemetry — canonical implementation
18
+ // moved from openclaw-plugin so both hosts share ONE semantics.
19
+ export * from './workspace-telemetry-emitter.js';
13
20
  // Anonymous Product Telemetry v1 (PRI-595~603) — opt-in, default-off,
14
21
  // read-only with respect to all PD governance facts.
15
22
  export * from './product-telemetry/consent-store.js';
@@ -0,0 +1,43 @@
1
+ import { type ConsumerGovernanceLogger } from './internalization-consumer-governance.js';
2
+ export declare const INTERNALIZATION_AUTO_CONSUMER_FLAG_ID = "internalization_auto_consumer";
3
+ /** Structural logger port — OpenClaw PluginLogger and worker loggers satisfy this. */
4
+ export interface ConsumerCycleLogger extends ConsumerGovernanceLogger {
5
+ info: (msg: string) => void;
6
+ warn: (msg: string) => void;
7
+ error: (msg: string) => void;
8
+ }
9
+ export interface ConsumerHostToolCatalog {
10
+ readonly readOnlyTools: readonly string[];
11
+ readonly writeTools: readonly string[];
12
+ }
13
+ export interface InternalizationConsumerCyclePorts {
14
+ /** Runner-owner label recorded on leases ('auto-consumer' | 'companion-worker' | …). */
15
+ readonly owner: string;
16
+ readonly logger: ConsumerCycleLogger;
17
+ /** Structured event sink (OpenClaw: SystemLogger.log; worker: its own log). */
18
+ readonly emitEvent: (event: string, payloadJson: string) => void;
19
+ /**
20
+ * Host-declared runtime-authoritative tool catalog (evaluator; PRI-630).
21
+ * Optional: hosts that have not declared a catalog (Codex as of PRI-624)
22
+ * omit it and the evaluator keeps its pre-catalog behavior — a wrong-host
23
+ * catalog would be worse than none.
24
+ */
25
+ readonly hostToolCatalog?: ConsumerHostToolCatalog;
26
+ /** Log label used in human log prefixes ('AutoConsumer' | 'CodexWorker' | …). */
27
+ readonly logLabel: string;
28
+ readonly envGetter?: (name: string) => string | undefined;
29
+ }
30
+ export interface InternalizationConsumerCycleOutcome {
31
+ readonly ran: boolean;
32
+ readonly skipReason?: string;
33
+ readonly taskId?: string;
34
+ readonly taskKind?: string;
35
+ readonly runStatus?: 'succeeded' | 'failed' | 'retried';
36
+ }
37
+ /**
38
+ * Run ONE bounded consumer cycle for a workspace. At most one downstream task
39
+ * is leased and executed per cycle (DEFAULT_CONSUMER_MAX_TASKS_PER_CYCLE=1);
40
+ * backlog converges over repeated cycles and never starves reconciliation
41
+ * (bounded budget in the finally block).
42
+ */
43
+ export declare function runInternalizationConsumerCycle(workspaceDir: string, ports: InternalizationConsumerCyclePorts): Promise<InternalizationConsumerCycleOutcome>;
@@ -0,0 +1,468 @@
1
+ /**
2
+ * Shared host-neutral internalization consumer cycle (PRI-624 Slice C).
3
+ *
4
+ * Extracted verbatim from openclaw-plugin's
5
+ * `internalization-auto-consumer-service.ts` `runConsumerCycle` so that the
6
+ * OpenClaw auto-consumer scheduler and the Companion workspace worker call
7
+ * ONE downstream execution implementation (SPEC §13: "reusing existing
8
+ * downstream consumer logic" — the Companion must not copy it):
9
+ *
10
+ * flag gate → config/runtime config → state handle → orchestrator(dryRun)
11
+ * → queue read model → consumer decision → wakeOnce loop → adapter
12
+ * → runner construction (dreamer…rollout_reviewer) → runner.run
13
+ * → commit proposal → finally: recovery sweep + reconciliation budget
14
+ *
15
+ * The lease is acquired INSIDE runner.run by the Runtime V2 lease manager
16
+ * (persisted in `<workspace>/.pd/state.db`, cross-process safe) — this cycle
17
+ * never leases by itself, so two schedulers (OpenClaw + Companion) racing on
18
+ * one workspace converge to exactly-once execution via lease_conflict.
19
+ *
20
+ * Hosts inject: a logger, a structured-event sink, their tool catalog, and an
21
+ * env getter. Everything else is the existing production path.
22
+ */
23
+ import { createHash } from 'node:crypto';
24
+ import { createRuntimeStateHandle, InternalizationOrchestrator, DreamerRunner, PhilosopherRunner, ScribeRunner, ArtificerRunner, EvaluatorRunner, RolloutReviewerRunner, DefaultDreamerValidator, DefaultPhilosopherValidator, DefaultScribeValidator, DefaultArtificerValidator, DefaultEvaluatorValidator, DefaultRolloutReviewerValidator, PiAiRuntimeAdapter, L2AgentLoopAdapter, buildL2PrincipleReaderFromLedger, OpenClawCliRuntimeAdapter, storeEmitter, createProductionGateDeps, resolveRuntimeConfigFromPdConfig, isRuntimeConfigError, computeConsumerDecision, FULL_CHAIN_CONSUMER_RUNNER_KINDS, DEFAULT_CONSUMER_RUNNER_KINDS, InternalizationQueueReadModel, MVP_CORE_TASK_KINDS, SqliteConnection, SqliteReconciliationCursorStore, SUCCEEDED_TRANSITIONS_SCOPE, } from '@principles/core/runtime-v2';
25
+ import { loadLedger } from '@principles/core/principle-tree-ledger';
26
+ import { loadPdConfigForPlugin, loadFeatureFlagFromConfig } from './pd-config.js';
27
+ import { createEvaluatorRepairDeps, createRolloutGovernanceDeps } from './internalization-consumer-governance.js';
28
+ import { WorkspaceTelemetryEmitter } from './workspace-telemetry-emitter.js';
29
+ export const INTERNALIZATION_AUTO_CONSUMER_FLAG_ID = 'internalization_auto_consumer';
30
+ function contentHashFn(text) {
31
+ return createHash('sha256').update(text).digest('hex');
32
+ }
33
+ function formatRunOnceCommand(workspaceDir) {
34
+ return `pd runtime internalization run-once --workspace "${workspaceDir}" --runner dreamer --runtime config --json`;
35
+ }
36
+ function getNextActionForError(category) {
37
+ if (category === 'lease_conflict') {
38
+ return 'Retry later or check for concurrent worker processes.';
39
+ }
40
+ if (category === 'timeout') {
41
+ return 'Check model provider service status/latency, or increase timeout settings in workflows.yaml.';
42
+ }
43
+ if (category === 'cancelled') {
44
+ return 'Re-enqueue or restart the task if it was cancelled by mistake.';
45
+ }
46
+ if (category === 'output_invalid') {
47
+ return 'Verify if model outputs conform to expected schema and adjust prompt or validation templates if needed.';
48
+ }
49
+ if (category === 'input_invalid') {
50
+ return 'Check predecessor task outputs and database integrity for malformed input references.';
51
+ }
52
+ if (category === 'max_attempts_exceeded') {
53
+ return 'Investigate persistent failures, correct the root issue, and clear last_error or reset attempt count.';
54
+ }
55
+ return 'Run: pd runtime internalization run-once --runner dreamer --runtime config --json to isolate the failure.';
56
+ }
57
+ /**
58
+ * PRI-554: safe per-cycle expired-lease recovery sweep.
59
+ * - sweep 失败不阻塞 consumer cycle (下轮再试); 显式留痕
60
+ * INTERNALIZATION_CONSUMER_RECOVER_FAILED (rc-9)。
61
+ */
62
+ async function safeRunRecoverySweep(stateManager, ports) {
63
+ const { logger, emitEvent, logLabel } = ports;
64
+ try {
65
+ const sweep = await stateManager.runRecoverySweep();
66
+ if (sweep.recovered > 0 || sweep.errors.length > 0) {
67
+ emitEvent('INTERNALIZATION_CONSUMER_RECOVERED', JSON.stringify({
68
+ recovered: sweep.recovered,
69
+ failed: sweep.errors.length,
70
+ errors: sweep.errors,
71
+ }));
72
+ logger.info(`[PD:${logLabel}] Recovery sweep: recovered=${sweep.recovered} failed=${sweep.errors.length}`);
73
+ }
74
+ }
75
+ catch (sweepErr) {
76
+ emitEvent('INTERNALIZATION_CONSUMER_RECOVER_FAILED', String(sweepErr));
77
+ logger.warn(`[PD:${logLabel}] Recovery sweep failed: ${String(sweepErr)}`);
78
+ }
79
+ }
80
+ /**
81
+ * A (crash window / 最终复核): bounded + fair + restart-durable 的
82
+ * succeeded-transition reconciliation 预算。
83
+ * - ASC 稳定全序 (SQL ORDER BY updated_at, task_id) + 独占元组游标;
84
+ * - 每周期 RECONCILIATION_BUDGET 条,扫到尾部 wrap-around 重置;
85
+ * - 游标持久化于 state.db reconciliation_cursor — restart 后继续;
86
+ * - P1: 任何 reconcile_error 显式留痕 (不依赖 recovered>0)。
87
+ */
88
+ const RECONCILIATION_BUDGET = 5;
89
+ async function runReconciliationBudget(workspaceDir, orchestrator, ports) {
90
+ const { logger, emitEvent, logLabel } = ports;
91
+ try {
92
+ const conn = new SqliteConnection(workspaceDir);
93
+ try {
94
+ const cursorStore = new SqliteReconciliationCursorStore(conn);
95
+ const stored = cursorStore.get(SUCCEEDED_TRANSITIONS_SCOPE);
96
+ const recon = await orchestrator.reconcileSucceededTransitions({
97
+ limit: RECONCILIATION_BUDGET,
98
+ cursor: stored ? { updatedAt: stored.lastUpdatedAt, taskId: stored.lastTaskId } : undefined,
99
+ logger: { info: (msg) => logger.info(msg) },
100
+ });
101
+ if (recon.wrappedAround) {
102
+ cursorStore.clear(SUCCEEDED_TRANSITIONS_SCOPE);
103
+ }
104
+ else {
105
+ cursorStore.set(SUCCEEDED_TRANSITIONS_SCOPE, recon.nextCursor);
106
+ }
107
+ const errors = recon.outcomes.filter((o) => o.decision.startsWith('reconcile_error'));
108
+ if (recon.recovered > 0 || errors.length > 0) {
109
+ emitEvent('INTERNALIZATION_CONSUMER_RECONCILED', JSON.stringify({
110
+ scanned: recon.scanned,
111
+ recovered: recon.recovered,
112
+ alreadyMaterialized: recon.alreadyMaterialized,
113
+ blocked: recon.blocked,
114
+ wrappedAround: recon.wrappedAround,
115
+ recoveries: recon.outcomes.filter((o) => o.decision === 'successor_created' || o.decision.includes('reopened')),
116
+ // P1 (最终复核): per-task 错误显式可观测 — 即使 recovered=0
117
+ errors,
118
+ }));
119
+ logger.info(`[PD:${logLabel}] Reconciliation: recovered=${recon.recovered} errors=${errors.length} wrapped=${recon.wrappedAround}`);
120
+ }
121
+ }
122
+ finally {
123
+ try {
124
+ conn.close();
125
+ }
126
+ catch { /* best-effort */ }
127
+ }
128
+ }
129
+ catch (reconErr) {
130
+ // reconciliation 失败不阻塞周期 (下轮再试);显式留痕 (rc-9)
131
+ emitEvent('INTERNALIZATION_CONSUMER_RECONCILE_FAILED', String(reconErr));
132
+ logger.warn(`[PD:${logLabel}] Succeeded-transition reconciliation failed: ${String(reconErr)}`);
133
+ }
134
+ }
135
+ /**
136
+ * Run ONE bounded consumer cycle for a workspace. At most one downstream task
137
+ * is leased and executed per cycle (DEFAULT_CONSUMER_MAX_TASKS_PER_CYCLE=1);
138
+ * backlog converges over repeated cycles and never starves reconciliation
139
+ * (bounded budget in the finally block).
140
+ */
141
+ export async function runInternalizationConsumerCycle(workspaceDir, ports) {
142
+ const { logger, emitEvent, logLabel, owner } = ports;
143
+ const { hostToolCatalog } = ports;
144
+ const envGetter = ports.envGetter ?? ((name) => process.env[name]);
145
+ let orchestrator = null;
146
+ const flag = loadFeatureFlagFromConfig(workspaceDir, INTERNALIZATION_AUTO_CONSUMER_FLAG_ID, {
147
+ info: (msg) => logger.info(msg),
148
+ warn: (msg) => logger.warn(msg),
149
+ });
150
+ if (!flag.enabled) {
151
+ const disabledInfo = JSON.stringify({
152
+ reason: 'internalization_auto_consumer_disabled',
153
+ nextAction: formatRunOnceCommand(workspaceDir),
154
+ flagSource: flag.source,
155
+ });
156
+ emitEvent('INTERNALIZATION_CONSUMER_SKIP', disabledInfo);
157
+ logger.info(`[PD:${logLabel}] Cycle skipped: auto-consumer disabled. Source: ${flag.source}`);
158
+ return { ran: false, skipReason: 'internalization_auto_consumer_disabled' };
159
+ }
160
+ const configResult = loadPdConfigForPlugin(workspaceDir);
161
+ if (!configResult.ok) {
162
+ const malformedInfo = JSON.stringify({
163
+ reason: 'config_malformed',
164
+ nextAction: configResult.errors[0]?.nextAction ?? 'Fix .pd/config.yaml and retry',
165
+ errors: configResult.errors.map((e) => e.reason),
166
+ });
167
+ emitEvent('INTERNALIZATION_CONSUMER_SKIP', malformedInfo);
168
+ logger.warn(`[PD:${logLabel}] Config malformed, skipping cycle.`);
169
+ return { ran: false, skipReason: 'config_malformed' };
170
+ }
171
+ const runtimeConfigResult = resolveRuntimeConfigFromPdConfig(configResult.effective, (name) => envGetter(name));
172
+ if (isRuntimeConfigError(runtimeConfigResult)) {
173
+ const rtInfo = JSON.stringify({
174
+ reason: 'runtime_config_error',
175
+ message: runtimeConfigResult.message,
176
+ nextAction: runtimeConfigResult.nextAction,
177
+ });
178
+ emitEvent('INTERNALIZATION_CONSUMER_SKIP', rtInfo);
179
+ logger.warn(`[PD:${logLabel}] Runtime config error: ${runtimeConfigResult.message}`);
180
+ return { ran: false, skipReason: 'runtime_config_error' };
181
+ }
182
+ let handle = null;
183
+ try {
184
+ handle = await createRuntimeStateHandle({ workspaceDir, readonly: false });
185
+ const { stateManager } = handle;
186
+ // A (最终复核): orchestrator 必须在所有队列状态相关的早退之前创建 —
187
+ // 纯 orphan 场景 (succeeded 后 crash、队列全空 → readyTaskCount=0) 恰恰
188
+ // 是 reconciliation 存在的理由; 若 orchestrator 为 null, finally 的
189
+ // bounded budget 不会执行, orphan 永远无法恢复。
190
+ const { runtimeKind } = runtimeConfigResult;
191
+ orchestrator = new InternalizationOrchestrator({ stateManager }, { owner, runtimeKind, dryRun: true });
192
+ const readModel = new InternalizationQueueReadModel(stateManager);
193
+ readModel.setPolicy({
194
+ enabledChannels: new Set(['prompt', 'code_tool_hook', 'defer_archive']),
195
+ actionableTaskKinds: new Set(MVP_CORE_TASK_KINDS),
196
+ });
197
+ const snapshot = await readModel.getSnapshot();
198
+ if (snapshot.readyTasks.length > 5) {
199
+ logger.warn(`[PD:${logLabel}] Backlog detected: ${snapshot.readyTasks.length} tasks ready. Processing only one task.`);
200
+ }
201
+ // PRI-419 amendment: when internalization_full_chain is ON (default), the
202
+ // consumer advances the full dreamer→…→evaluator→rollout_reviewer chain
203
+ // so artifacts reach validation_status='validated' and the approval queue is
204
+ // populated unattended. The human gate is the approval queue (Console), not
205
+ // the rollout_reviewer step. flag-off reverts to dreamer-only.
206
+ const fullChainFlag = loadFeatureFlagFromConfig(workspaceDir, 'internalization_full_chain');
207
+ const consumerRunnerKinds = fullChainFlag.enabled
208
+ ? FULL_CHAIN_CONSUMER_RUNNER_KINDS
209
+ : DEFAULT_CONSUMER_RUNNER_KINDS;
210
+ const decision = computeConsumerDecision({
211
+ autoConsumerEnabled: true,
212
+ readyTaskCount: snapshot.readyTasks.length,
213
+ runnerKinds: consumerRunnerKinds,
214
+ });
215
+ if (!decision.shouldConsume) {
216
+ // 早退不跳过 reconciliation — finally 的 bounded budget 仍会执行
217
+ emitEvent('INTERNALIZATION_CONSUMER_SKIP', JSON.stringify({
218
+ reason: decision.reason,
219
+ readyTaskCount: snapshot.readyTasks.length,
220
+ }));
221
+ return { ran: false, skipReason: decision.reason };
222
+ }
223
+ // Advance the configured runner kinds in priority order (dreamer first,
224
+ // then philosopher→…→rollout_reviewer under full-chain scope). Lease the
225
+ // first ready task whose dependencies are satisfied.
226
+ let wakeResult = null;
227
+ let lastSkipDecision = 'no_ready_tasks';
228
+ let lastSkipReason;
229
+ for (const kind of decision.runnerKinds) {
230
+ const candidate = await orchestrator.wakeOnce(kind);
231
+ if (candidate.decision === 'would_lease') {
232
+ wakeResult = candidate;
233
+ break;
234
+ }
235
+ // Keep decision/reason paired across iterations so the final SKIP log
236
+ // never reports a stale reason from an earlier kind (EP-03 observability).
237
+ lastSkipDecision = candidate.decision;
238
+ lastSkipReason = candidate.decision === 'no_ready_tasks' ? candidate.reason : undefined;
239
+ }
240
+ if (!wakeResult) {
241
+ // A: 预算由周期 finally 统一执行 (每周期恰一次,含 backlog 场景)
242
+ const skipPayload = { decision: lastSkipDecision };
243
+ if (lastSkipReason) {
244
+ skipPayload.reason = lastSkipReason;
245
+ }
246
+ emitEvent('INTERNALIZATION_CONSUMER_SKIP', JSON.stringify(skipPayload));
247
+ logger.info(`[PD:${logLabel}] No task to consume: ${lastSkipDecision}`);
248
+ return { ran: false, skipReason: lastSkipDecision };
249
+ }
250
+ let adapter;
251
+ if (runtimeKind === 'pi-ai') {
252
+ // PRI-419: when l2_dreamer flag is on AND this is a dreamer task, route
253
+ // through the L2 multi-turn agent loop. Non-dreamer runners always use PiAi.
254
+ const l2Flag = loadFeatureFlagFromConfig(workspaceDir, 'l2_dreamer');
255
+ if (l2Flag.enabled && wakeResult.taskKind === 'dreamer') {
256
+ const stateDir = `${workspaceDir}/.state`;
257
+ const principleReader = buildL2PrincipleReaderFromLedger(loadLedger(stateDir), {
258
+ logger: { warn: (msg) => logger.warn(msg) },
259
+ });
260
+ adapter = new L2AgentLoopAdapter({
261
+ provider: runtimeConfigResult.provider ?? 'openai',
262
+ model: runtimeConfigResult.model ?? 'gpt-4o',
263
+ apiKeyEnv: runtimeConfigResult.apiKeyEnv ?? 'OPENAI_API_KEY',
264
+ baseUrl: runtimeConfigResult.baseUrl,
265
+ workspace: workspaceDir,
266
+ totalBudgetMs: runtimeConfigResult.timeoutMs,
267
+ }, {
268
+ artifactReader: {
269
+ // Explicit adapter: PIArtifactRecord → PdL2ArtifactReader. The store returns
270
+ // PIArtifactRecord (with PIArtifactKind enum); map to the ArtifactSummary shape.
271
+ getArtifactById: async (id) => {
272
+ const r = await stateManager.piArtifactStore.getArtifactById(id);
273
+ return r ? { artifactId: r.artifactId, artifactKind: String(r.artifactKind), sourceTaskId: r.sourceTaskId, contentJson: r.contentJson, createdAt: r.createdAt } : null;
274
+ },
275
+ listBySourceTaskId: async (taskId) => {
276
+ const records = await stateManager.piArtifactStore.listBySourceTaskId(taskId);
277
+ return records.map(r => ({ artifactId: r.artifactId, artifactKind: String(r.artifactKind), sourceTaskId: r.sourceTaskId, contentJson: r.contentJson, createdAt: r.createdAt }));
278
+ },
279
+ },
280
+ principleReader,
281
+ });
282
+ }
283
+ else {
284
+ adapter = new PiAiRuntimeAdapter({
285
+ provider: runtimeConfigResult.provider ?? 'openai',
286
+ model: runtimeConfigResult.model ?? 'gpt-4o',
287
+ apiKeyEnv: runtimeConfigResult.apiKeyEnv ?? 'OPENAI_API_KEY',
288
+ maxRetries: runtimeConfigResult.maxRetries,
289
+ maxTokens: runtimeConfigResult.maxTokens,
290
+ timeoutMs: runtimeConfigResult.timeoutMs,
291
+ baseUrl: runtimeConfigResult.baseUrl,
292
+ workspace: workspaceDir,
293
+ });
294
+ }
295
+ }
296
+ else if (runtimeKind === 'openclaw-cli') {
297
+ adapter = new OpenClawCliRuntimeAdapter({
298
+ runtimeMode: runtimeConfigResult.openclawMode ?? 'default',
299
+ workspaceDir: workspaceDir,
300
+ });
301
+ }
302
+ else {
303
+ throw new Error(`Unsupported runtime kind resolved for auto-consumer: ${runtimeKind}`);
304
+ }
305
+ const { taskId } = wakeResult;
306
+ const { taskKind } = wakeResult;
307
+ // Issue 2: forward effectiveConfig so runners can resolve feature flags
308
+ // (e.g. `artificer_output_retry`) — mirrors the diagnostician wiring
309
+ // (ADR-0019). Flag-off / absent = legacy behavior.
310
+ const runnerOptions = { owner, runtimeKind, effectiveConfig: configResult.effective };
311
+ // PRI-634 A3: workspace-scoped telemetry sink for the evaluator runner.
312
+ // Constructed here (per-wake, workspaceDir in scope) so events from THIS
313
+ // workspace's runner are attributable to THIS workspace — a global
314
+ // subscriber on the storeEmitter singleton cannot (multi-workspace
315
+ // isolation, see workspace-telemetry-emitter.ts). Persist failures
316
+ // degrade through the host's structured event port, never break the runner.
317
+ const evaluatorEmitter = new WorkspaceTelemetryEmitter(storeEmitter, workspaceDir, (detail) => {
318
+ emitEvent('WORKSPACE_TELEMETRY_PERSIST_FAILED', detail);
319
+ });
320
+ // Dispatch by leased task kind. Only kinds listed in
321
+ // FULL_CHAIN_CONSUMER_RUNNER_KINDS can be leased here; anything else
322
+ // (e.g. diagnostician) hits the default branch — fail loud if it does (EP-03).
323
+ let runner;
324
+ switch (taskKind) {
325
+ case 'dreamer':
326
+ runner = new DreamerRunner({ stateManager, runtimeAdapter: adapter, eventEmitter: storeEmitter, artifactStore: stateManager.piArtifactStore, validator: new DefaultDreamerValidator(), contentHashFn }, runnerOptions);
327
+ break;
328
+ case 'philosopher':
329
+ runner = new PhilosopherRunner({ stateManager, runtimeAdapter: adapter, eventEmitter: storeEmitter, artifactStore: stateManager.piArtifactStore, validator: new DefaultPhilosopherValidator(), contentHashFn }, runnerOptions);
330
+ break;
331
+ case 'scribe':
332
+ runner = new ScribeRunner({ stateManager, runtimeAdapter: adapter, eventEmitter: storeEmitter, artifactStore: stateManager.piArtifactStore, validator: new DefaultScribeValidator(), contentHashFn }, runnerOptions);
333
+ break;
334
+ case 'artificer':
335
+ runner = new ArtificerRunner({ stateManager, runtimeAdapter: adapter, eventEmitter: storeEmitter, artifactStore: stateManager.piArtifactStore, validator: new DefaultArtificerValidator(), contentHashFn }, runnerOptions);
336
+ break;
337
+ case 'evaluator':
338
+ // P0-D 生产接线: PRI-509 repair loop 正式进入 consumer (bounded,
339
+ // flag evaluator_artificer_repair_loop 保留运行时关闭能力)。needs_revision
340
+ // → seed artificer repair; commit 门控保证不再并行 seed rollout_reviewer。
341
+ // PRI-630: 注入宿主声明的 runtime-authoritative 工具目录 — 工具名合法
342
+ // 性以目录为准,禁止 LLM 凭记忆判 "非标准工具名" (链 48371236 根因②)。
343
+ // PRI-634 A1/A3: (a) 注入 canonical production gateDeps — 此前缺省导致
344
+ // adversarial replay 结构性不可达 (链 48371236 根因 A1); (b) evaluator
345
+ // 的事件改走 workspace-scoped emitter, 只把 4 类 critical events 落盘到
346
+ // <workspaceDir>/.pd/telemetry/critical-events.jsonl, 其余照旧转发全局
347
+ // storeEmitter (multi-workspace 隔离, 见 workspace-telemetry-emitter.ts)。
348
+ // gateDeps 属于 evaluator production semantics, 不是宿主可选能力 —
349
+ // 无论 OpenClaw 还是 Codex worker 执行, deterministic gate wiring
350
+ // 必须存在 (第二个 options 参数, 绝不放第一个 deps 参数)。
351
+ runner = new EvaluatorRunner({
352
+ stateManager, runtimeAdapter: adapter, eventEmitter: evaluatorEmitter,
353
+ artifactStore: stateManager.piArtifactStore, validator: new DefaultEvaluatorValidator(),
354
+ ...createEvaluatorRepairDeps(workspaceDir, stateManager, logger),
355
+ }, {
356
+ ...runnerOptions,
357
+ gateDeps: createProductionGateDeps(),
358
+ ...(hostToolCatalog
359
+ ? { hostToolCatalog: { readOnlyTools: [...hostToolCatalog.readOnlyTools], writeTools: [...hostToolCatalog.writeTools] } }
360
+ : {}),
361
+ });
362
+ break;
363
+ case 'rollout_reviewer':
364
+ // Lineage echo reconciliation is built into the runner: LLM-truncated
365
+ // taskId / sourceEvaluatorArtifactId echoes are overwritten with the
366
+ // authoritative values before validation, so a bad echo no longer
367
+ // dead-ends the candidate before the approval queue.
368
+ //
369
+ // P0-E/F 治理接线: approve_rollout → 自动 ActivationDispatcher (低风险
370
+ // auto_activate / 高风险 approvals.pending); needs_revision → reopen
371
+ // scribe/artificer 修订 (绝不进入 approval 队列, INV-04)。
372
+ runner = new RolloutReviewerRunner({
373
+ stateManager, runtimeAdapter: adapter, eventEmitter: storeEmitter,
374
+ artifactStore: stateManager.piArtifactStore, validator: new DefaultRolloutReviewerValidator(),
375
+ ...createRolloutGovernanceDeps(workspaceDir, orchestrator, logger),
376
+ }, runnerOptions);
377
+ break;
378
+ default:
379
+ emitEvent('INTERNALIZATION_CONSUMER_SKIP', JSON.stringify({ decision: 'no_runner_for_kind', taskKind }));
380
+ logger.warn(`[PD:${logLabel}] No consumer runner for task kind '${taskKind}'; skipping. Advance manually: pd runtime internalization run-once --runner ${taskKind}`);
381
+ return { ran: false, skipReason: 'no_runner_for_kind', taskKind };
382
+ }
383
+ logger.info(`[PD:${logLabel}] Running ${taskKind} task: ${taskId}`);
384
+ emitEvent('INTERNALIZATION_CONSUMER_RUN', JSON.stringify({
385
+ taskId,
386
+ taskKind,
387
+ }));
388
+ let runResult;
389
+ try {
390
+ runResult = await runner.run(taskId);
391
+ }
392
+ catch (runErr) {
393
+ logger.error(`[PD:${logLabel}] Runner crashed for task ${taskId}: ${String(runErr)}`);
394
+ try {
395
+ const task = await stateManager.getTask(taskId);
396
+ const failureReason = `Unhandled runner exception: ${runErr instanceof Error ? runErr.message : String(runErr)}`;
397
+ if (task && stateManager.getRetryPolicy().shouldRetry(task)) {
398
+ await stateManager.markTaskRetryWait(taskId, 'execution_failed', failureReason);
399
+ logger.info(`[PD:${logLabel}] Marked task ${taskId} as retry_wait.`);
400
+ }
401
+ else {
402
+ await stateManager.markTaskFailed(taskId, 'execution_failed', failureReason);
403
+ logger.info(`[PD:${logLabel}] Marked task ${taskId} as failed.`);
404
+ }
405
+ }
406
+ catch (dbErr) {
407
+ logger.error(`[PD:${logLabel}] Failed to update state for crashed task ${taskId}: ${String(dbErr)}`);
408
+ }
409
+ throw runErr;
410
+ }
411
+ if (runResult.status === 'succeeded') {
412
+ let commitResult = null;
413
+ try {
414
+ commitResult = await orchestrator.commitNextTaskProposal(taskId);
415
+ }
416
+ catch (commitErr) {
417
+ // The task itself succeeded — a successor-proposal failure must not
418
+ // misreport the run; surface it as its own structured event (rc-9).
419
+ emitEvent('INTERNALIZATION_CONSUMER_COMMIT_FAILED', JSON.stringify({ taskId, error: String(commitErr).slice(0, 300) }));
420
+ logger.warn(`[PD:${logLabel}] Task ${taskId} succeeded but successor proposal failed: ${String(commitErr).slice(0, 200)}`);
421
+ }
422
+ emitEvent('INTERNALIZATION_CONSUMER_SUCCESS', JSON.stringify({
423
+ taskId,
424
+ status: runResult.status,
425
+ ...(commitResult ? { successorDecision: commitResult.decision } : { successorDecision: 'commit_failed' }),
426
+ }));
427
+ logger.info(`[PD:${logLabel}] Task ${taskId} succeeded. Successor: ${commitResult ? commitResult.decision : 'commit_failed'}`);
428
+ return { ran: true, taskId, taskKind, runStatus: 'succeeded' };
429
+ }
430
+ const { errorCategory } = runResult;
431
+ const { failureReason } = runResult;
432
+ const nextAction = getNextActionForError(errorCategory);
433
+ emitEvent('INTERNALIZATION_CONSUMER_TASK_FAILED', JSON.stringify({
434
+ taskId,
435
+ status: runResult.status,
436
+ errorCategory,
437
+ failureReason,
438
+ nextAction,
439
+ }));
440
+ logger.warn(`[PD:${logLabel}] Task ${taskId} status: ${runResult.status}. Category: ${errorCategory}. Reason: ${failureReason}. Next Action: ${nextAction}`);
441
+ return { ran: true, taskId, taskKind, runStatus: runResult.status === 'retried' ? 'retried' : 'failed' };
442
+ }
443
+ catch (err) {
444
+ emitEvent('INTERNALIZATION_CONSUMER_ERROR', String(err));
445
+ logger.error(`[PD:${logLabel}] Cycle error: ${String(err)}`);
446
+ return { ran: false, skipReason: 'cycle_error' };
447
+ }
448
+ finally {
449
+ // PRI-554: per-cycle expired-lease recovery sweep. Worker process death
450
+ // mid-lease leaves tasks invisible to findCandidates (pending/retry_wait
451
+ // only); without this sweep they stay leased forever. Runs before
452
+ // reconciliation and is gated on `handle` (not `orchestrator`) so it still
453
+ // executes if the orchestrator constructor threw — enumerate every early
454
+ // return between resource-open and the finally steps (ERR-024).
455
+ if (handle) {
456
+ await safeRunRecoverySweep(handle.stateManager, ports);
457
+ }
458
+ // A (最终复核): 每周期固定小预算 — continuous backlog 下 reconciliation
459
+ // 不会被 ready 任务永久饿死 (公平性);游标 restart-durable。
460
+ // 必须在 handle.close() 之前执行 (orchestrator 共享该 stateManager)。
461
+ if (orchestrator) {
462
+ await runReconciliationBudget(workspaceDir, orchestrator, ports);
463
+ }
464
+ if (handle) {
465
+ await handle.close().catch(() => undefined);
466
+ }
467
+ }
468
+ }