diff --git a/app/src/components/intelligence/CodingSessionsCard.tsx b/app/src/components/intelligence/CodingSessionsCard.tsx
index 28829762c27..ea44d42ab12 100644
--- a/app/src/components/intelligence/CodingSessionsCard.tsx
+++ b/app/src/components/intelligence/CodingSessionsCard.tsx
@@ -59,8 +59,10 @@ export function CodingSessionsCard({ onToast }: CodingSessionsCardProps) {
}),
[sources]
);
- const hasImportableHistory =
- totals.files > 0 || sources.some(source => source.scan_truncated === true);
+ const scopedCodex = sources.find(source => source.kind === 'codex' && source.project_scope);
+ const hasImportableHistory = scopedCodex
+ ? scopedCodex.session_files > 0 || scopedCodex.scan_truncated === true
+ : totals.files > 0 || sources.some(source => source.scan_truncated === true);
const ingest = useCallback(async () => {
console.debug('[coding-sessions] drain: entry');
@@ -108,11 +110,17 @@ export function CodingSessionsCard({ onToast }: CodingSessionsCardProps) {
: t('memorySources.codingSessions.completeMessage')
.replace('{processed}', String(result.sessionsProcessed))
.replace('{observations}', String(result.observations));
+ const diagnostics = result.failures
+ ?.slice(0, 10)
+ .map(failure => failure.summary)
+ .join(' ');
const title = result.timedOut
? t('memorySources.codingSessions.stillRunning')
- : incomplete
- ? t('memorySources.codingSessions.stopped')
- : t('memorySources.codingSessions.complete');
+ : result.sessionsFailed > 0
+ ? t('memorySources.codingSessions.failed')
+ : incomplete
+ ? t('memorySources.codingSessions.stopped')
+ : t('memorySources.codingSessions.complete');
onToast?.({
type: result.timedOut
? 'info'
@@ -125,6 +133,7 @@ export function CodingSessionsCard({ onToast }: CodingSessionsCardProps) {
message,
});
await load();
+ if (!result.timedOut && result.sessionsFailed > 0) setError(diagnostics || message);
} catch (cause) {
console.error('[coding-sessions] drain failed', cause);
const message = cause instanceof Error ? cause.message : String(cause);
@@ -181,6 +190,14 @@ export function CodingSessionsCard({ onToast }: CodingSessionsCardProps) {
+ {scopedCodex && (
+
+ {t('memorySources.codingSessions.projectScope').replace(
+ '{project}',
+ scopedCodex.project_scope ?? ''
+ )}
+
+ )}
{ingesting && progress && (
0
+ ? ` · ${t('memorySources.codingSessions.checkpoints').replace('{count}', String(progress.checkpointsAdvanced))}`
+ : ''}
{progress.moreRemaining
? ` · ${t('memorySources.codingSessions.remaining').replace(
'{remaining}',
@@ -210,10 +230,24 @@ export function CodingSessionsCard({ onToast }: CodingSessionsCardProps) {
{source.available
? t('memorySources.codingSessions.counts')
- .replace('{files}', String(source.session_files))
- .replace('{evidence}', String(source.evidence_units))
+ .replace(
+ '{files}',
+ `${source.session_files}${source.scan_truncated ? '+' : ''}`
+ )
+ .replace(
+ '{evidence}',
+ `${source.evidence_units}${source.scan_truncated ? '+' : ''}`
+ )
: t('memorySources.codingSessions.notFound')}
+ {source.kind === 'codex' && (source.sessions_excluded ?? 0) > 0 && (
+
+ {t('memorySources.codingSessions.excluded').replace(
+ '{count}',
+ String(source.sessions_excluded)
+ )}
+
+ )}
{source.available && source.scan_truncated && (
{t('memorySources.codingSessions.truncated')}
diff --git a/app/src/components/intelligence/__tests__/CodingSessionsCard.test.tsx b/app/src/components/intelligence/__tests__/CodingSessionsCard.test.tsx
index 9ba2be2c9d1..337d0acb350 100644
--- a/app/src/components/intelligence/__tests__/CodingSessionsCard.test.tsx
+++ b/app/src/components/intelligence/__tests__/CodingSessionsCard.test.tsx
@@ -288,7 +288,9 @@ describe('CodingSessionsCard', () => {
]);
renderWithProviders();
- expect(await screen.findByText('Scan limited to the first 1,000 session files.')).toBeVisible();
+ expect(
+ await screen.findByText('Scan reached its file or byte limit. Counts are lower bounds.')
+ ).toBeVisible();
});
it('keeps ingestion enabled when a capped scan has not found evidence yet', async () => {
@@ -307,3 +309,83 @@ describe('CodingSessionsCard', () => {
expect(await screen.findByTestId('coding-sessions-ingest')).toBeEnabled();
});
});
+
+describe('Codex import diagnostics', () => {
+ it('shows scope, exclusions and lower-bound scan counts', async () => {
+ mockedStatus.mockResolvedValue([
+ {
+ kind: 'codex',
+ available: true,
+ session_files: 12,
+ evidence_units: 20,
+ invalid_files: 0,
+ scan_truncated: true,
+ project_scope: '/workspace/project',
+ sessions_excluded: 3,
+ },
+ ]);
+ renderWithProviders();
+ expect(
+ await screen.findByText('Importing Codex sessions for /workspace/project only.')
+ ).toBeInTheDocument();
+ expect(screen.getByTestId('coding-session-source-codex')).toHaveTextContent(
+ '12+ sessions · 20+ human turns'
+ );
+ expect(screen.getByTestId('coding-session-source-codex')).toHaveTextContent(
+ '3 sessions excluded'
+ );
+ });
+
+ it('keeps actionable partial failure visible and never announces success', async () => {
+ mockedStatus.mockResolvedValue([
+ { kind: 'codex', available: true, session_files: 2, evidence_units: 2, invalid_files: 0 },
+ ]);
+ mockedDrain.mockResolvedValue({
+ passes: 1,
+ sessionsProcessed: 1,
+ sessionsFailed: 1,
+ observations: 2,
+ remaining: 1,
+ moreRemaining: true,
+ timedOut: false,
+ failures: [
+ {
+ code: 'provider_failure',
+ session_id: 'opaque',
+ summary: 'Check provider access and retry.',
+ },
+ ],
+ });
+ const onToast = vi.fn();
+ renderWithProviders();
+ fireEvent.click(await screen.findByTestId('coding-sessions-ingest'));
+ expect(await screen.findByRole('alert')).toHaveTextContent('Check provider access and retry.');
+ expect(onToast).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'warning', title: 'Coding-session ingestion failed' })
+ );
+ });
+});
+
+it('shows saved checkpoint progress before a session completes', async () => {
+ mockedStatus.mockResolvedValue([
+ { kind: 'codex', available: true, session_files: 1, evidence_units: 100, invalid_files: 0 },
+ ]);
+ mockedDrain.mockImplementation(({ onProgress } = {}) => {
+ onProgress?.({
+ passes: 1,
+ sessionsProcessed: 0,
+ sessionsFailed: 0,
+ observations: 0,
+ remaining: 1,
+ moreRemaining: true,
+ timedOut: false,
+ checkpointsAdvanced: 5,
+ });
+ return new Promise(() => {});
+ });
+ renderWithProviders();
+ fireEvent.click(await screen.findByTestId('coding-sessions-ingest'));
+ expect(await screen.findByTestId('coding-sessions-progress')).toHaveTextContent(
+ '5 digest checkpoints saved'
+ );
+});
diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts
index 990426720fe..159071d7bb7 100644
--- a/app/src/lib/i18n/en.ts
+++ b/app/src/lib/i18n/en.ts
@@ -7466,6 +7466,10 @@ const en: TranslationMap = {
'memoryBudget.exhaustedMessage':
'Your embedding budget is used up, so new content is no longer being added to memory. Set up local embeddings or add your own API key to resume.',
'memoryBudget.cta': 'Set up embeddings',
+ 'memorySources.codingSessions.checkpoints': '{count} digest checkpoints saved',
+ 'memorySources.codingSessions.projectScope': 'Importing Codex sessions for {project} only.',
+ 'memorySources.codingSessions.excluded':
+ '{count} sessions excluded: project provenance is missing or outside the selected project.',
'memorySources.codingSessions.title': 'Coding-agent sessions',
'memorySources.codingSessions.description':
'Turn your Codex and Claude Code decisions and corrections into private persona memory.',
@@ -7483,7 +7487,8 @@ const en: TranslationMap = {
'memorySources.codingSessions.counts': '{files} sessions · {evidence} human turns',
'memorySources.codingSessions.notFound': 'No local history found',
'memorySources.codingSessions.scanning': 'Scanning local session history…',
- 'memorySources.codingSessions.truncated': 'Scan limited to the first 1,000 session files.',
+ 'memorySources.codingSessions.truncated':
+ 'Scan reached its file or byte limit. Counts are lower bounds.',
'memorySources.codingSessions.complete': 'Coding sessions ingested',
'memorySources.codingSessions.completeMessage':
'{processed} sessions produced {observations} persona observations.',
diff --git a/app/src/services/memorySourcesService.test.ts b/app/src/services/memorySourcesService.test.ts
index 4a590403bf3..13e709adb9d 100644
--- a/app/src/services/memorySourcesService.test.ts
+++ b/app/src/services/memorySourcesService.test.ts
@@ -415,3 +415,60 @@ describe('memorySourcesService', () => {
expect(result.moreRemaining).toBe(true);
});
});
+
+describe('Codex checkpoint reports', () => {
+ it('continues after checkpoint progress without a completed session', async () => {
+ const base = {
+ mode: 'incremental',
+ files_seen: 1,
+ sessions_skipped: 0,
+ sessions_failed: 0,
+ evidence_units: 20,
+ observations: 0,
+ };
+ mockedCall.mockReset();
+ mockedCall.mockResolvedValueOnce({
+ result: { ...base, sessions_processed: 0, checkpoints_advanced: 5, budget_hit: true },
+ logs: [],
+ } as never);
+ mockedCall.mockResolvedValueOnce({
+ result: { ...base, sessions_processed: 1, checkpoints_advanced: 2, budget_hit: false },
+ logs: [],
+ } as never);
+ const result = await drainCodingSessions();
+ expect(mockedCall).toHaveBeenCalledTimes(2);
+ expect(result.sessionsProcessed).toBe(1);
+ expect(result.checkpointsAdvanced).toBe(7);
+ expect(result.moreRemaining).toBe(false);
+ });
+
+ it('retains failures and stops automatic retry even with a remaining budget', async () => {
+ mockedCall.mockReset();
+ const failures = [
+ {
+ code: 'parse_failure',
+ session_id: 'opaque',
+ summary: 'Check the summarisation provider and retry.',
+ },
+ ];
+ mockedCall.mockResolvedValue({
+ result: {
+ mode: 'incremental',
+ files_seen: 2,
+ sessions_processed: 1,
+ sessions_skipped: 0,
+ sessions_failed: 1,
+ evidence_units: 2,
+ observations: 1,
+ checkpoints_advanced: 1,
+ failures,
+ budget_hit: true,
+ },
+ logs: [],
+ } as never);
+ const result = await drainCodingSessions();
+ expect(mockedCall).toHaveBeenCalledTimes(1);
+ expect(result.moreRemaining).toBe(true);
+ expect(result.failures).toEqual(failures);
+ });
+});
diff --git a/app/src/services/memorySourcesService.ts b/app/src/services/memorySourcesService.ts
index d032126cd40..707702ef00c 100644
--- a/app/src/services/memorySourcesService.ts
+++ b/app/src/services/memorySourcesService.ts
@@ -252,6 +252,14 @@ export interface CodingSessionSourceStatus {
evidence_units: number;
invalid_files: number;
scan_truncated?: boolean;
+ project_scope?: string | null;
+ sessions_excluded?: number;
+}
+
+export interface CodingSessionFailure {
+ code: string;
+ session_id: string;
+ summary: string;
}
export interface CodingSessionIngestResult {
@@ -264,6 +272,9 @@ export interface CodingSessionIngestResult {
observations: number;
budget_hit: boolean;
pack_path?: string | null;
+ checkpoints_advanced?: number;
+ failures?: CodingSessionFailure[];
+ sessions_excluded?: number;
}
// A single ingest RPC is bounded so it fits under the core RPC client's hard
@@ -331,6 +342,10 @@ export async function ingestCodingSessions(
}
export interface CodingSessionDrainProgress {
+ /** Newly persisted digest pieces across completed passes. */
+ checkpointsAdvanced?: number;
+ /** Sanitised diagnostics from the latest pass. */
+ failures?: CodingSessionFailure[];
/** Bounded ingest RPC passes completed so far in this drain. */
passes: number;
/** Sessions distilled across every pass in this drain. */
@@ -440,6 +455,8 @@ export async function drainCodingSessions(
remaining: 0,
moreRemaining: false,
timedOut: false,
+ checkpointsAdvanced: 0,
+ failures: [],
};
log('drain_coding_sessions: entry max_per_pass=%d max_passes=%d', maxSessionsPerPass, maxPasses);
@@ -471,6 +488,9 @@ export async function drainCodingSessions(
progress.passes += 1;
progress.sessionsProcessed += result.sessions_processed;
progress.sessionsFailed = result.sessions_failed;
+ progress.checkpointsAdvanced =
+ (progress.checkpointsAdvanced ?? 0) + (result.checkpoints_advanced ?? 0);
+ progress.failures = result.failures ?? [];
progress.observations += result.observations;
// files_seen is the discovered total for this scan; skipped + processed is
// what this pass accounted for, so the remainder is the honest backlog.
@@ -478,9 +498,10 @@ export async function drainCodingSessions(
0,
result.files_seen - result.sessions_skipped - result.sessions_processed
);
- progress.moreRemaining = result.budget_hit;
+ progress.moreRemaining = result.budget_hit || result.sessions_failed > 0;
onProgress?.({ ...progress });
+ if (result.sessions_failed > 0) break;
if (!result.budget_hit) {
log(
'drain_coding_sessions: drained after pass=%d processed=%d',
@@ -489,7 +510,7 @@ export async function drainCodingSessions(
);
break;
}
- if (result.sessions_processed === 0) {
+ if (result.sessions_processed === 0 && (result.checkpoints_advanced ?? 0) === 0) {
// The backlog still reports more work, but this pass distilled nothing
// new — every remaining candidate failed or could not advance. Stop
// rather than spin; the caller surfaces the retained failures.
diff --git a/crates/openhuman-core/src/memory/sources/rpc/coding_sessions.rs b/crates/openhuman-core/src/memory/sources/rpc/coding_sessions.rs
index 89591948c47..ff9b3d10d6e 100644
--- a/crates/openhuman-core/src/memory/sources/rpc/coding_sessions.rs
+++ b/crates/openhuman-core/src/memory/sources/rpc/coding_sessions.rs
@@ -175,6 +175,10 @@ pub async fn ingest_coding_sessions_rpc(
));
};
+ // Check the existing consent/provider ladder before any transcript can be
+ // sent to inference. This reports a missing step without changing settings.
+ validate_ingestion_provider(&config)?;
+
// Wall-clock ceiling so a stalled provider call or a wedged session step
// can't keep the RPC waiting indefinitely (#4863 review), sized to the
// requested backfill so a legitimate large run isn't killed mid-flight
@@ -205,3 +209,19 @@ pub async fn ingest_coding_sessions_rpc(
);
Ok(Outcome::new(report, vec![]))
}
+
+/// Refuse missing processing prerequisites without changing consent or routing.
+fn validate_ingestion_provider(config: &Config) -> Result<(), String> {
+ if !config.local_ai.runtime_enabled && !config.memory_tree.cloud_summarization_opt_in {
+ return Err("cloud_processing_disabled: enable local AI, or explicitly opt in to cloud memory processing before importing sessions".into());
+ }
+ let (available, _) = crate::memory::tree::tree_runtime::ops::summarizer_available(config);
+ if !available {
+ return Err("summarization_unavailable: configure a summarisation provider in Connections, or check that your local AI model is available".into());
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+#[path = "coding_sessions_tests.rs"]
+mod tests;
diff --git a/crates/openhuman-core/src/memory/sources/rpc/coding_sessions_tests.rs b/crates/openhuman-core/src/memory/sources/rpc/coding_sessions_tests.rs
new file mode 100644
index 00000000000..285e186fab8
--- /dev/null
+++ b/crates/openhuman-core/src/memory/sources/rpc/coding_sessions_tests.rs
@@ -0,0 +1,38 @@
+//! Coding-session ingestion prerequisite checks.
+use super::*;
+
+#[test]
+fn cloud_opt_out_is_refused_before_provider_construction_and_stays_unchanged() {
+ let mut config = Config::default();
+ config.local_ai.runtime_enabled = false;
+ config.memory_tree.cloud_summarization_opt_in = false;
+ let before = config.memory_provider.clone();
+ let error = validate_ingestion_provider(&config).unwrap_err();
+ assert!(error.starts_with("cloud_processing_disabled:"));
+ assert!(!config.memory_tree.cloud_summarization_opt_in);
+ assert_eq!(config.memory_provider, before);
+}
+
+#[test]
+fn local_processing_passes_preflight_without_cloud_opt_in() {
+ let mut config = Config::default();
+ config.local_ai.runtime_enabled = true;
+ config.local_ai.chat_model_id = "offline-fixture".into();
+ config.memory_tree.cloud_summarization_opt_in = false;
+ assert!(validate_ingestion_provider(&config).is_ok());
+ assert!(!config.memory_tree.cloud_summarization_opt_in);
+}
+
+#[test]
+fn missing_provider_reports_the_step_without_changing_the_route() {
+ let mut config = Config::default();
+ config.local_ai.runtime_enabled = false;
+ config.memory_tree.cloud_summarization_opt_in = true;
+ config.memory_provider = Some("invalid-fixture-provider:model".into());
+ let error = validate_ingestion_provider(&config).unwrap_err();
+ assert!(error.starts_with("summarization_unavailable:"));
+ assert_eq!(
+ config.memory_provider.as_deref(),
+ Some("invalid-fixture-provider:model")
+ );
+}
diff --git a/docs/RELEASE-MANUAL-SMOKE.md b/docs/RELEASE-MANUAL-SMOKE.md
index b3008c2205d..bb64e0e7c1b 100644
--- a/docs/RELEASE-MANUAL-SMOKE.md
+++ b/docs/RELEASE-MANUAL-SMOKE.md
@@ -129,3 +129,5 @@ Notes:
```
Paste the filled block as a commit comment on the `v-staging` tagged commit before promoting to production.
+
+- [ ] **Codex session import** — On Brain > Sources, import a Codex history with a long session and pause between passes. Expected: saved checkpoints advance before session completion and resume without re-sending completed windows. With a configured driver project scope, only matching Codex sessions are imported and exclusions are explained. Missing processing consent/provider shows an actionable alert without changing settings; partial failures never show a success toast.
diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md
index 54625141c30..fd98b0eb204 100644
--- a/docs/TEST-COVERAGE-MATRIX.md
+++ b/docs/TEST-COVERAGE-MATRIX.md
@@ -354,7 +354,7 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an
| 8.2.2 | Memory Consistency | RI | `tests/worker_c_modules_e2e.rs` | ✅ | Worker C RPC E2E verifies memory-tree ingest is reflected by `memory_sync_status_list` |
| 8.2.3 | Memory Scaling | RU | `vendor/tinymemory/crates/tinymemory-core/src/ingest_pipeline_tests.rs`, `vendor/tinymemory/crates/tinymemory-core/src/ingestion/queue_tests.rs` (vendored `tinymemory`) | 🟡 | Ingestion moved into the `tinymemory` module; soak/scale benchmark not asserted |
| 8.2.4 | Raw-archive sync reconcile | RU+RI | `crates/openhuman-core/src/memory/sources/reconcile_tests.rs`, `crates/openhuman-core/src/memory/sources/sync_tests.rs`, `vendor/tinymemory/crates/tinymemory-core/src/sync/workspace/periodic_tests.rs` (vendored `tinymemory`), `tests/json_rpc_e2e.rs` (`json_rpc_memory_sources_reconcile_reports_pending_raw_files`), `tests/memory_sources_e2e.rs` | ✅ | Coverage gate + incremental rebuild + workspace periodic scheduler + `memory_sources_reconcile` RPC |
-| 8.2.5 | Coding-session persona ingestion | RU+RI+VU+WD | `vendor/tinymemory/crates/tinymemory-core/src/engine/persona_tests.rs` (vendored `tinymemory`), `crates/openhuman-core/src/memory/sources/rpc_tests.rs`, `tests/json_rpc_e2e.rs`, `app/src/components/intelligence/__tests__/CodingSessionsCard.test.tsx`, `app/src/services/memorySourcesService.test.ts`, `app/test/e2e/specs/coding-session-memory.spec.ts`, `app/test/playwright/specs/coding-session-memory.spec.ts` | ✅ | Discovers Codex and Claude Code histories, excludes machine-authored turns, exposes status/ingest RPCs, and surfaces incremental ingestion on Brain > Sources |
+| 8.2.5 | Coding-session persona ingestion | RU+RI+VU+WD | `vendor/tinymemory/crates/tinymemory-core/src/engine/persona_tests.rs` (vendored `tinymemory`), `crates/openhuman-core/src/memory/sources/rpc_tests.rs`, `crates/openhuman-core/src/memory/sources/rpc/coding_sessions_tests.rs`, `vendor/tinymemory/vendor/tinycortex/src/memory/persona/checkpoint_tests.rs`, `tests/json_rpc_e2e.rs`, `app/src/components/intelligence/__tests__/CodingSessionsCard.test.tsx`, `app/src/services/memorySourcesService.test.ts`, `app/test/e2e/specs/coding-session-memory.spec.ts`, `app/test/playwright/specs/coding-session-memory.spec.ts` | ✅ | Discovers Codex and Claude Code histories, excludes machine-authored turns, exposes status/ingest RPCs, and surfaces incremental ingestion on Brain > Sources; Codex project filtering, durable digest checkpoints, safe diagnostics and processing preflight |
| 8.2.6 | Folder-source path picker | RU+VU | `crates/openhuman-app/src/directory_picker.rs`, `app/src/utils/tauriCommands/directoryPicker.test.ts`, `app/src/components/intelligence/FolderField.test.tsx` | ✅ | Browse resolves an absolute path through the OS-native chooser and refuses to store a non-resolvable value (bare directory name) when none is available (#5831) |
| 8.2.7 | Sync History and live sync activity | RU+RI+VU | `crates/openhuman-core/src/memory/sources/run_history_tests.rs`, `crates/openhuman-core/src/memory/sources/rpc/driver_run_tests.rs`, `crates/openhuman-core/src/memory/sources/rpc_monthly_summary_tests_tests.rs`, `crates/openhuman-core/src/integrations/composio/ops/connector_runs_tests.rs`, `crates/openhuman-core/src/integrations/composio/ops/source_rows_tests.rs`, `crates/openhuman-core/src/memory/sync_activity_tests.rs`, `tests/memory_sources_e2e.rs` (`sources_sync_dispatches_to_the_module_rather_than_refusing_the_capability`), `app/src/components/intelligence/SyncAuditPanel.test.tsx`, `app/src/components/intelligence/SyncActivityCard.test.tsx`, `app/src/components/intelligence/__tests__/memorySyncActivityStore.test.ts`, `app/src/components/intelligence/MemorySourceRow.test.tsx`, `app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx` | ✅ | Every host-driven run (Sync button, Apply all, Composio) leaves one history row merged with the driver's audit log; manual driver runs end in `completed` or `failed`; a late per-document stage cannot reopen a finished row; Brain > Sync shows what is syncing and the job queue, and re-reads the history when a run ends (#6257) |
| 8.2.8 | Selectable memory engines and migration | RU+RI+VU | `crates/openhuman-core/src/memory/ops/engine_migrate_tests.rs`, `tests/memory_engine_e2e.rs`, `tests/memory_engine_migrate_e2e.rs`, `app/src/components/settings/panels/MemoryEnginePanel.test.tsx`, `app/src/components/intelligence/MemoryFamilyGate.test.tsx` | ✅ | Engine selection, migration copy/cancel/failure behavior, hosted and external drivers, and capability gating |
diff --git a/vendor/tinymemory b/vendor/tinymemory
index 99cdd6e33fb..f979b6805a2 160000
--- a/vendor/tinymemory
+++ b/vendor/tinymemory
@@ -1 +1 @@
-Subproject commit 99cdd6e33fbf6ecb1ce5d50af9526b6002af0f9a
+Subproject commit f979b6805a2fd9458a9404f30711121caf03cd91