Skip to content

Commit 8d86405

Browse files
authored
Merge pull request #1240 from paullizer/fix/tabular-analyze-truncation-schema-deferral
Fix tabular Analyze truncation and combined-run schema lock
2 parents 18db546 + c27b0ff commit 8d86405

13 files changed

Lines changed: 1384 additions & 29 deletions

application/single_app/background_tasks.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -705,7 +705,10 @@ def run_file_sync_scheduler_loop():
705705
try:
706706
lock_document = acquire_distributed_task_lock('file_sync_scheduler_scan', lease_seconds=300)
707707
if lock_document:
708-
check_due_file_sync_sources_once()
708+
due_sources = check_due_file_sync_sources_once()
709+
debug_print(f"File Sync scheduler tick processed {len(due_sources or [])} source(s).")
710+
else:
711+
debug_print('Skipping File Sync scheduler tick because another worker holds the lease.')
709712
except Exception as exc:
710713
print(f"Error in File Sync scheduler check: {exc}")
711714
log_event(f"[FILE_SYNC] Error in scheduler check: {exc}", level=logging.ERROR)
@@ -723,7 +726,14 @@ def run_tabular_generated_output_scheduler_loop():
723726
try:
724727
lock_document = acquire_distributed_task_lock('tabular_generated_output_scheduler_scan', lease_seconds=120)
725728
if lock_document:
726-
check_due_tabular_generated_output_runs_once()
729+
processed_run_ids = check_due_tabular_generated_output_runs_once()
730+
debug_print(
731+
f"Tabular generated-output scheduler tick processed {len(processed_run_ids or [])} run(s)."
732+
)
733+
else:
734+
debug_print(
735+
'Skipping tabular generated-output scheduler tick because another worker holds the lease.'
736+
)
727737
except Exception as exc:
728738
print(f"Error in tabular generated-output scheduler check: {exc}")
729739
log_event(f"[TABULAR_GENERATED_OUTPUT] Error in scheduler check: {exc}", level=logging.ERROR)
@@ -741,7 +751,10 @@ def run_data_management_scheduler_loop(app=None):
741751
try:
742752
lock_document = acquire_distributed_task_lock('data_management_scheduler_scan', lease_seconds=300)
743753
if lock_document:
744-
check_due_data_management_jobs_once(app=app)
754+
due_jobs = check_due_data_management_jobs_once(app=app)
755+
debug_print(f"Data Management scheduler tick processed {len(due_jobs or [])} job(s).")
756+
else:
757+
debug_print('Skipping Data Management scheduler tick because another worker holds the lease.')
745758
except Exception as exc:
746759
print(f"Error in Data Management scheduler check: {exc}")
747760
log_event(f"[DATA_MANAGEMENT] Error in scheduler check: {exc}", level=logging.ERROR)

application/single_app/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@
9696
EXECUTOR_TYPE = 'thread'
9797
EXECUTOR_MAX_WORKERS = 30
9898
SESSION_TYPE = 'filesystem'
99-
VERSION = "0.250.185"
99+
VERSION = "0.250.191"
100100
IS_DEVELOPMENT = is_development_env_enabled()
101101

102102
SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax')

application/single_app/functions_settings.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import app_settings_cache
1919
import inspect
2020
import copy
21+
import os
2122
import json
2223
import uuid
2324
from support_menu_config import (
@@ -1010,6 +1011,25 @@ def _update_cache(stage):
10101011
_update_cache("after_version_bump")
10111012

10121013

1014+
def _env_flag_enabled(name):
1015+
return str(os.environ.get(name, '')).strip().lower() in {'1', 'true', 'yes', 'on'}
1016+
1017+
1018+
def _apply_tabular_parity_env_kill_switch(settings_payload):
1019+
"""Force tabular durable-preflight parity off when the emergency env kill switch is set.
1020+
1021+
These parity controls ship active by default with no admin UI toggle; this
1022+
environment variable is the only rollback path for an operator incident.
1023+
"""
1024+
if not isinstance(settings_payload, dict):
1025+
return settings_payload
1026+
if _env_flag_enabled('SIMPLECHAT_DISABLE_TABULAR_PARITY_DURABLE_PREFLIGHT'):
1027+
settings_payload['tabular_request_planner_mode'] = 'off'
1028+
settings_payload['enable_tabular_search_shared_preflight'] = False
1029+
settings_payload['enable_tabular_analyze_durable_preflight'] = False
1030+
return settings_payload
1031+
1032+
10131033
def get_settings(use_cosmos=False, include_source=False):
10141034
import secrets
10151035
default_settings = {
@@ -1048,9 +1068,9 @@ def get_settings(use_cosmos=False, include_source=False):
10481068
'tabular_analyze_parity_rollout_percent': 100,
10491069
'tabular_analyze_parity_rollout_state': 'active',
10501070
'tabular_background_handoff_mode': 'legacy',
1051-
'tabular_request_planner_mode': 'off',
1052-
'enable_tabular_search_shared_preflight': False,
1053-
'enable_tabular_analyze_durable_preflight': False,
1071+
'tabular_request_planner_mode': 'active',
1072+
'enable_tabular_search_shared_preflight': True,
1073+
'enable_tabular_analyze_durable_preflight': True,
10541074
'enable_tabular_mixed_deferred_composition_planning': False,
10551075
'enable_tabular_multifile_execution_unit_planning': False,
10561076
'tabular_legacy_post_tool_fallback_mode': 'enabled',
@@ -1615,6 +1635,8 @@ def get_settings(use_cosmos=False, include_source=False):
16151635
}
16161636

16171637
def _format_result(settings_payload, source):
1638+
if isinstance(settings_payload, dict):
1639+
settings_payload = _apply_tabular_parity_env_kill_switch(settings_payload)
16181640
if include_source:
16191641
return settings_payload, source
16201642
return settings_payload

application/single_app/functions_tabular_generated_exports.py

Lines changed: 75 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
ANALYSIS_ARTIFACT_ROLE_PRIMARY_ANALYSIS,
4343
ANALYSIS_ARTIFACT_ROLE_REQUESTED_OUTPUT,
4444
ANALYSIS_ARTIFACT_ROLE_SUPPORTING_OUTPUT,
45+
ANALYSIS_DELIVERABLE_MAX_ARTIFACT_ID_LENGTH,
46+
ANALYSIS_DELIVERABLE_MAX_ARTIFACTS,
4547
build_analysis_deliverable_contract,
4648
is_analysis_internal_lineage_field,
4749
project_structured_deliverable_row,
@@ -6855,6 +6857,17 @@ def _normalize_tabular_run_planner_metadata(planner_metadata):
68556857
'action_mode': str(deliverable_contract.get('action_mode') or '').strip().lower()[:40],
68566858
'analysis_required': bool(deliverable_contract.get('analysis_required')),
68576859
'primary_artifact_role': str(deliverable_contract.get('primary_artifact_role') or '').strip().lower()[:80],
6860+
'requested_artifacts': [
6861+
{
6862+
'artifact_id': str(artifact.get('artifact_id') or '').strip()[:ANALYSIS_DELIVERABLE_MAX_ARTIFACT_ID_LENGTH],
6863+
'role': str(artifact.get('role') or '').strip().lower()[:40],
6864+
'format': str(artifact.get('format') or '').strip().lower()[:20],
6865+
'required': bool(artifact.get('required', True)),
6866+
'request_order': _safe_int(artifact.get('request_order'), default=0, minimum=0),
6867+
}
6868+
for artifact in list(deliverable_contract.get('requested_artifacts') or [])[:ANALYSIS_DELIVERABLE_MAX_ARTIFACTS]
6869+
if isinstance(artifact, dict) and str(artifact.get('artifact_id') or '').strip()
6870+
],
68586871
'public_output_schema': [
68596872
str(field_name or '').strip()
68606873
for field_name in list(deliverable_contract.get('public_output_schema') or [])[:TABULAR_GENERATION_PLAN_MAX_FIELDS]
@@ -8689,6 +8702,33 @@ def _build_or_update_artifact_set_manifest(run):
86898702
'rollback_state': str(existing_manifest.get('rollback_state') or '').strip().lower()[:40],
86908703
'members': members,
86918704
}
8705+
if (
8706+
str((run or {}).get('status') or '').strip().lower() == TABULAR_EXPORT_STATUS_COMPLETED
8707+
and lifecycle_state != TABULAR_ARTIFACT_SET_LIFECYCLE_COMPLETED
8708+
):
8709+
log_event(
8710+
'[TABULAR_GENERATED_OUTPUT] Artifact set stuck below completed lifecycle on a completed run',
8711+
{
8712+
'run_id': run.get('id'),
8713+
'conversation_id': run.get('conversation_id'),
8714+
'task_type': manifest.get('task_type'),
8715+
'persisted_lifecycle_state': str(existing_manifest.get('lifecycle_state') or ''),
8716+
'recomputed_lifecycle_state': lifecycle_state,
8717+
'validation_state': manifest.get('validation_state'),
8718+
'validation_report': existing_manifest.get('validation_report'),
8719+
'members': [
8720+
{
8721+
'member_id': member.get('member_id'),
8722+
'role': member.get('role'),
8723+
'lifecycle_state': member.get('lifecycle_state'),
8724+
'validation_state': member.get('validation_state'),
8725+
'has_artifact_message_id': bool(member.get('artifact_message_id')),
8726+
}
8727+
for member in members
8728+
],
8729+
},
8730+
level=logging.WARNING,
8731+
)
86928732
return manifest
86938733

86948734

@@ -8738,13 +8778,33 @@ def _publish_artifact_set_members(run, published_member_ids):
87388778
]
87398779
deliverable_contract = _get_tabular_run_deliverable_contract(run)
87408780
artifact_set_valid = True
8781+
validation_report = None
87418782
if deliverable_contract:
87428783
validation_report = validate_analysis_artifact_set(deliverable_contract, validation_artifacts)
87438784
artifact_set_valid = validation_report.valid
87448785
manifest['validation_state'] = 'validated' if validation_report.valid else 'invalid'
87458786
manifest['validation_report'] = validation_report.to_dict()
87468787
else:
87478788
manifest['validation_state'] = 'validated'
8789+
log_event(
8790+
'[TABULAR_GENERATED_OUTPUT] Artifact set publication validation',
8791+
{
8792+
'run_id': run.get('id'),
8793+
'conversation_id': run.get('conversation_id'),
8794+
'task_type': _normalize_tabular_run_task_type(run.get('task_type')),
8795+
'published_member_ids': sorted(published_ids),
8796+
'has_deliverable_contract': bool(deliverable_contract),
8797+
'artifact_set_valid': artifact_set_valid,
8798+
'reason_codes': list(validation_report.reason_codes) if validation_report else [],
8799+
'counts': dict(validation_report.counts) if validation_report else {},
8800+
'validation_artifacts': validation_artifacts,
8801+
'expected_artifact_ids': [
8802+
artifact.get('artifact_id')
8803+
for artifact in list((deliverable_contract or {}).get('requested_artifacts') or [])
8804+
],
8805+
},
8806+
level=logging.INFO,
8807+
)
87488808
manifest['lifecycle_state'] = (
87498809
TABULAR_ARTIFACT_SET_LIFECYCLE_COMPLETED
87508810
if artifact_set_valid
@@ -10946,7 +11006,9 @@ def queue_tabular_generated_output_run(
1094611006
'planner_started_at': None,
1094711007
'planner_completed_at': None,
1094811008
'processed_rows': 0,
10949-
'output_schema': contract_internal_checkpoint_schema or None,
11009+
# Only lock the schema up front when real output columns are already known; otherwise
11010+
# defer to batch-1 discovery instead of validating against a lineage-only placeholder.
11011+
'output_schema': contract_internal_checkpoint_schema if contract_public_output_schema else None,
1095011012
'public_output_schema': contract_public_output_schema,
1095111013
'internal_checkpoint_schema': contract_internal_checkpoint_schema,
1095211014
'lineage_schema': [
@@ -11103,17 +11165,16 @@ def check_due_tabular_generated_output_runs_once(limit=None):
1110311165
'reason': f"{candidate.get('reason')}; claim or processing did not start",
1110411166
})
1110511167

11106-
if scanned_candidates or candidates:
11107-
log_event(
11108-
'[TABULAR_GENERATED_OUTPUT] Background scheduler scan result',
11109-
{
11110-
'scanned_count': len(scanned_candidates),
11111-
'candidate_count': len(candidates),
11112-
'status_counts': status_counts,
11113-
'processed_run_ids': processed,
11114-
'processed_count': len(processed),
11115-
'skipped': skipped[:10],
11116-
},
11117-
debug_only=True,
11118-
)
11168+
log_event(
11169+
'[TABULAR_GENERATED_OUTPUT] Background scheduler scan result',
11170+
{
11171+
'scanned_count': len(scanned_candidates),
11172+
'candidate_count': len(candidates),
11173+
'status_counts': status_counts,
11174+
'processed_run_ids': processed,
11175+
'processed_count': len(processed),
11176+
'skipped': skipped[:10],
11177+
},
11178+
debug_only=True,
11179+
)
1111911180
return processed

docs/explanation/features/TABULAR_ANALYZE_SEARCH_PARITY_ROLLOUT.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,15 @@ Configuration options:
3737

3838
All Phase 8 rollout controls are backend-only and are removed by `sanitize_settings_for_user()` before non-admin frontend settings are returned.
3939

40+
> **Update (0.250.186):** `tabular_request_planner_mode` now defaults to `active` and both `enable_tabular_search_shared_preflight` and `enable_tabular_analyze_durable_preflight` default to `True`. Bounded foreground synthesis for exhaustive row-by-row tabular requests is no longer the default behavior. There is intentionally no admin UI toggle for this; use the `SIMPLECHAT_DISABLE_TABULAR_PARITY_DURABLE_PREFLIGHT` environment variable for emergency rollback (see below).
41+
4042
## Usage Instructions
4143

4244
How to enable/configure:
43-
1. Keep the planner mode `off` for the default legacy behavior.
44-
2. Use `shadow` mode to compare planner decisions without queueing shared-planner durable work.
45-
3. Enable the relevant single-source Search or Analyze gate before switching that mode to active traffic.
46-
4. Adjust `tabular_analyze_parity_rollout_percent` for canary cohorts.
45+
1. The planner mode defaults to `active`; no configuration is required for normal operation.
46+
2. Use `shadow` mode only to compare planner decisions without queueing shared-planner durable work, for example while validating a new model or source type.
47+
3. Set the environment variable `SIMPLECHAT_DISABLE_TABULAR_PARITY_DURABLE_PREFLIGHT=true` to force `tabular_request_planner_mode` to `off` and both shared-preflight flags to `False` for every request during an incident; unset it to restore the active default.
48+
4. Adjust `tabular_analyze_parity_rollout_percent` for canary cohorts if a partial rollout is needed.
4749
5. Keep `tabular_legacy_post_tool_fallback_mode='enabled'` until operator telemetry shows no required legacy recovery traffic.
4850

4951
Operator telemetry:
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Tabular Analyze/Search Parity Default Activation Fix
2+
3+
Fixed in version: **0.250.186**
4+
5+
## Issue Description
6+
7+
Customer testing showed Chat/Analyze still answering exhaustive row-by-row tabular requests (for example, "for each row, answer these eight questions") by producing detailed answers for roughly the first row and a half, then stating the remaining rows were unprocessed, truncated, or outside a bounded evidence handoff. This matched the exact failure mode the multi-phase tabular Analyze/Search parity roadmap (`feature/tabular-analyze-search-parity`, `feature/analyze-artifact-output-contract`) was built to eliminate.
8+
9+
## Root Cause Analysis
10+
11+
The durable-preflight parity path (`_maybe_execute_pure_tabular_analyze_preflight` in `functions_workflow_runner.py` for Analyze, and `maybe_queue_search_tabular_generated_output` in `route_backend_chats.py` for Search) is gated by three backend-only settings: `tabular_request_planner_mode`, `enable_tabular_search_shared_preflight`, and `enable_tabular_analyze_durable_preflight`. All three defaulted to `off`/`False` in `functions_settings.py`, and none had an admin UI toggle, so no deployed environment ever ran the durable, exhaustive-coverage path unless an operator manually edited the stored settings document directly (there was no supported way to do this from the Admin Settings UI). Every request instead fell back to the legacy bounded foreground path, which answers using only the tool-call rows that fit in one synthesis turn and explicitly reports the rest as missing/truncated evidence.
12+
13+
## Version Implemented
14+
15+
Fixed in version: **0.250.186**
16+
17+
## Technical Details
18+
19+
### Files Modified
20+
21+
- `application/single_app/functions_settings.py`
22+
- `application/single_app/config.py`
23+
- `docs/reference/admin_configuration.md`
24+
- `docs/explanation/features/TABULAR_ANALYZE_SEARCH_PARITY_ROLLOUT.md`
25+
- `functional_tests/test_tabular_analyze_search_parity_default_activation.py`
26+
27+
### Code Changes Summary
28+
29+
- `tabular_request_planner_mode` now defaults to `active` (was `off`).
30+
- `enable_tabular_search_shared_preflight` and `enable_tabular_analyze_durable_preflight` now default to `True` (were `False`).
31+
- Added `_apply_tabular_parity_env_kill_switch()` in `functions_settings.py`, applied in `get_settings()`'s `_format_result()` choke point on every return path. When the environment variable `SIMPLECHAT_DISABLE_TABULAR_PARITY_DURABLE_PREFLIGHT` is truthy, it forces `tabular_request_planner_mode` back to `off` and both shared-preflight flags back to `False`, regardless of the stored settings document. This gives operators an emergency rollback path without requiring an admin UI toggle or a direct settings edit, consistent with treating always-on behavior as "on unless an operator opts out," not "off until an operator opts in."
32+
- `enable_tabular_mixed_deferred_composition_planning` and `enable_tabular_multifile_execution_unit_planning` remain `False` by default; per existing documentation these are planning-only metadata controls with no implemented durable execution behind them yet, so enabling them would not change runtime behavior.
33+
34+
### Testing Approach
35+
36+
- New functional test asserts the three defaults via AST-based extraction of `get_settings()`'s literal `default_settings` dict (avoids importing the full Flask app), and asserts the env kill switch forces them back off.
37+
- Re-ran the existing tabular parity suites (`test_tabular_shared_request_planner.py`, `test_tabular_analyze_shared_preflight_adapter.py`, `test_tabular_search_shared_preflight_adapter.py`, `test_analyze_artifact_phase7_rollout_rollback.py`, `test_tabular_phase8_ui_telemetry_rollout.py`, `test_tabular_execution_settings_sanitization.py`) to confirm no regressions; all pass unchanged because they construct explicit settings fixtures rather than relying on `get_settings()` defaults.
38+
- Compiled all changed Python files.
39+
40+
## Impact Analysis
41+
42+
Chat, Search, and Analyze now route exhaustive per-row/per-source tabular requests through the durable generated-output path by default, matching the behavior validated across the parity roadmap's Phases 1-9 and the analyze-artifact-output-contract Phases 1-7D. Operators who need to roll back during an incident set one environment variable instead of editing settings directly; no code deploy or Cosmos edit is required to disable, and none is required to re-enable.
43+
44+
## Validation
45+
46+
### Before
47+
48+
- `tabular_request_planner_mode=off`, `enable_tabular_search_shared_preflight=False`, `enable_tabular_analyze_durable_preflight=False` in every environment by default.
49+
- Exhaustive row-by-row Chat/Analyze requests answered a small bounded subset of rows, then reported the remainder as unprocessed/truncated evidence, even though the durable parity infrastructure to answer exhaustively already existed and was fully merged.
50+
51+
### After
52+
53+
- New defaults route exhaustive tabular requests through the durable preflight/generated-output path automatically.
54+
- `functional_tests/test_tabular_analyze_search_parity_default_activation.py` passes 2/2.
55+
- Existing tabular parity regression suites remain green.
56+
57+
## Related Version Updates
58+
59+
- `application/single_app/config.py` was updated to version **0.250.186**.

0 commit comments

Comments
 (0)