sino-bc-cli 1.8.41 → 1.8.42

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 (26) hide show
  1. package/dist/index.js +1 -1
  2. package/dist/release-info.json +2 -2
  3. package/dist/skill-manifest.json +24 -14
  4. package/dist/skills/biz-line-strategy-generate/SKILL.md +20 -17
  5. package/dist/skills/biz-line-strategy-generate/references/api-flow.md +16 -5
  6. package/dist/skills/biz-line-strategy-generate/references/read-tools.md +5 -2
  7. package/dist/skills/biz-line-strategy-generate/references/runtime.md +57 -18
  8. package/dist/skills/biz-line-strategy-generate/references/spec.md +56 -15
  9. package/dist/skills/biz-line-strategy-generate/scripts/ai_strategy.py +36 -22
  10. package/dist/skills/biz-line-strategy-generate/scripts/cleanup.py +9 -0
  11. package/dist/skills/biz-line-strategy-generate/scripts/resumable_delivery.py +184 -0
  12. package/dist/skills/biz-line-strategy-generate/scripts/resumable_requests.py +243 -0
  13. package/dist/skills/biz-line-strategy-generate/scripts/resumable_workflow.py +460 -0
  14. package/dist/skills/biz-line-strategy-generate/scripts/run_sharded.py +3 -0
  15. package/dist/skills/biz-line-strategy-generate/scripts/run_workflow.py +76 -17
  16. package/dist/skills/biz-line-strategy-generate/scripts/strategy_output.py +33 -15
  17. package/dist/skills/biz-line-strategy-generate/scripts/submit_ai_strategy.py +30 -0
  18. package/dist/skills/biz-line-strategy-generate/scripts/task_state.py +174 -0
  19. package/dist/skills/biz-line-strategy-generate/scripts/test_resource_cleanup.py +16 -0
  20. package/dist/skills/biz-line-strategy-generate/scripts/test_resumable_delivery.py +198 -0
  21. package/dist/skills/biz-line-strategy-generate/scripts/test_resumable_integration.py +301 -0
  22. package/dist/skills/biz-line-strategy-generate/scripts/test_resumable_requests.py +269 -0
  23. package/dist/skills/biz-line-strategy-generate/scripts/test_resumable_workflow.py +187 -0
  24. package/dist/skills/biz-line-strategy-generate/scripts/test_sharded.py +9 -0
  25. package/dist/skills/biz-line-strategy-generate/scripts/test_task_state.py +165 -0
  26. package/package.json +1 -1
@@ -0,0 +1,198 @@
1
+ """正式入库恢复:离线验证写入意图、回包恢复和查询认领。"""
2
+ import json
3
+ import tempfile
4
+ import threading
5
+ import unittest
6
+ from pathlib import Path
7
+ from unittest.mock import Mock, patch
8
+
9
+ from resumable_delivery import UnknownWrite, perform_write, read_intent, recover_write
10
+
11
+
12
+ def ready_body():
13
+ body = {'bizLineId': 31, 'periodStart': '2026-10-12', 'periodEnd': '2026-10-18',
14
+ 'generateCode': 'READY', 'fileStorageKey': 'test-file-key'}
15
+ for key in ('budget', 'media', 'audience', 'creative'):
16
+ body[key + 'Strategy' + ('' if key == 'creative' else 'Supplement')] = key + '正文'
17
+ body[key + 'StrategyEffect'] = 3
18
+ body[key + 'StrategyLearning'] = key + '学习'
19
+ if key != 'creative':
20
+ body[key + 'StrategyTags'] = [2, 1]
21
+ return body
22
+
23
+
24
+ def stored_row(body, ident=91):
25
+ return {('weekStart' if key == 'periodStart' else 'weekEnd' if key == 'periodEnd' else key): value
26
+ for key, value in body.items()} | {'id': ident}
27
+
28
+
29
+ def page(rows, total=100):
30
+ return {'ok': True, 'data': {'result': rows, 'total': total}}
31
+
32
+
33
+ class DeliveryTests(unittest.TestCase):
34
+ def setUp(self):
35
+ self.temp = tempfile.TemporaryDirectory()
36
+ self.root = Path(self.temp.name)
37
+ self.addCleanup(self.temp.cleanup)
38
+
39
+ def pending(self, kind, body):
40
+ with self.assertRaises(UnknownWrite):
41
+ perform_write(self.root, kind, body, Mock(side_effect=TimeoutError('模拟超时')))
42
+
43
+ def test_success_persists_id_before_return_and_never_replays(self):
44
+ body = ready_body()
45
+ operation = Mock(return_value=91)
46
+ self.assertEqual(perform_write(self.root, 'save', body, operation), 91)
47
+ self.assertEqual(read_intent(self.root, 'save')['status'], 'done')
48
+ self.assertEqual(perform_write(self.root, 'save', body, operation), 91)
49
+ operation.assert_called_once()
50
+
51
+ def test_upload_success_is_recovered_from_bound_output(self):
52
+ self.pending('upload', {'file': 'final.xlsx'})
53
+ (self.root / 'upload.out.json').write_text(json.dumps({'ok': True, 'data': {'storageKey': 'uploaded-key'}}))
54
+ self.assertEqual(recover_write(self.root, 'upload'), 'uploaded-key')
55
+ self.assertEqual(read_intent(self.root, 'upload')['status'], 'done')
56
+
57
+ def test_save_success_output_wins_over_query(self):
58
+ self.pending('save', ready_body())
59
+ (self.root / 'save.out.json').write_text(json.dumps({'ok': True, 'data': 91}))
60
+ with patch('resumable_delivery.run_cli') as cli:
61
+ self.assertEqual(recover_write(self.root, 'save'), 91)
62
+ cli.assert_not_called()
63
+
64
+ def test_upload_unknown_does_not_replay_or_query_ai(self):
65
+ self.pending('upload', {'file': 'final.xlsx'})
66
+ operation = Mock(return_value='unwanted')
67
+ with patch('resumable_delivery.run_cli') as cli, self.assertRaises(UnknownWrite):
68
+ perform_write(self.root, 'upload', {'file': 'final.xlsx'}, operation)
69
+ operation.assert_not_called()
70
+ cli.assert_not_called()
71
+
72
+ def test_query_uses_complete_pages_and_ignores_changing_total(self):
73
+ body = ready_body()
74
+ self.pending('save', body)
75
+ row = stored_row(body)
76
+ row['budgetStrategyTags'] = [1, 2]
77
+ with patch('resumable_delivery.run_cli', side_effect=[page([row], 1), page([], 7)]) as cli:
78
+ self.assertEqual(recover_write(self.root, 'save'), 91)
79
+ request = cli.call_args_list[0].args[1]
80
+ self.assertEqual(request['weekStart'], body['periodStart'])
81
+ self.assertNotIn('periodStart', request)
82
+ self.assertEqual(cli.call_count, 2)
83
+
84
+ def test_second_page_match_conflict_stays_unknown(self):
85
+ body = ready_body()
86
+ self.pending('save', body)
87
+ pages = [page([stored_row(body, 91)]), page([stored_row(body, 92)]), page([])] * 3
88
+ with patch('resumable_delivery.run_cli', side_effect=pages), patch('resumable_delivery.time.sleep'), \
89
+ self.assertRaises(UnknownWrite):
90
+ recover_write(self.root, 'save')
91
+ self.assertEqual(read_intent(self.root, 'save')['status'], 'pending')
92
+
93
+ def test_content_mismatch_is_not_success(self):
94
+ body = ready_body()
95
+ self.pending('save', body)
96
+ row = stored_row(body)
97
+ row['creativeStrategyLearning'] = '其他内容'
98
+ with patch('resumable_delivery.run_cli', side_effect=[page([row]), page([])] * 3), \
99
+ patch('resumable_delivery.time.sleep'), self.assertRaises(UnknownWrite):
100
+ recover_write(self.root, 'save')
101
+
102
+ def test_nonready_same_record_cannot_be_claimed(self):
103
+ body = {'bizLineId': 31, 'periodStart': '2026-10-12', 'periodEnd': '2026-10-18',
104
+ 'generateCode': 'NO_AD_ACCOUNT', 'remark': '没有账号'}
105
+ self.pending('save', body)
106
+ with patch('resumable_delivery.run_cli', side_effect=[page([stored_row(body)]), page([])] * 3) as cli, \
107
+ patch('resumable_delivery.time.sleep'), self.assertRaises(UnknownWrite):
108
+ recover_write(self.root, 'save')
109
+ self.assertGreater(cli.call_count, 0)
110
+
111
+ def test_incomplete_or_failed_query_cannot_claim_seen_match(self):
112
+ body = ready_body()
113
+ self.pending('save', body)
114
+ with patch('resumable_delivery.run_cli', side_effect=[page([stored_row(body)]), RuntimeError('断网')] * 3), \
115
+ patch('resumable_delivery.time.sleep'), self.assertRaises(UnknownWrite):
116
+ recover_write(self.root, 'save')
117
+
118
+ def test_stale_output_without_intent_is_rejected(self):
119
+ (self.root / 'save.out.json').write_text(json.dumps({'ok': True, 'data': 91}))
120
+ operation = Mock(return_value=92)
121
+ with self.assertRaises(ValueError):
122
+ perform_write(self.root, 'save', ready_body(), operation)
123
+ operation.assert_not_called()
124
+
125
+ def test_changed_body_cannot_reuse_intent(self):
126
+ body = ready_body()
127
+ perform_write(self.root, 'save', body, Mock(return_value=91))
128
+ with self.assertRaises(ValueError):
129
+ perform_write(self.root, 'save', body | {'fileStorageKey': 'different'}, Mock())
130
+
131
+ def test_cancel_during_operation_keeps_intent_pending(self):
132
+ with self.assertRaises(InterruptedError):
133
+ perform_write(self.root, 'save', ready_body(), Mock(side_effect=InterruptedError('中断')))
134
+ self.assertEqual(read_intent(self.root, 'save')['status'], 'pending')
135
+
136
+ def test_success_output_recovers_after_checkpoint_write_failure(self):
137
+ body = ready_body()
138
+ from task_state import atomic_json
139
+ writes = []
140
+ def fail_done(path, value):
141
+ writes.append(value['status'])
142
+ if value['status'] == 'done':
143
+ raise OSError('模拟状态写入失败')
144
+ atomic_json(path, value)
145
+ def operation():
146
+ (self.root / 'save.out.json').write_text(json.dumps({'ok': True, 'data': 91}))
147
+ return 91
148
+ with patch('resumable_delivery.atomic_json', side_effect=fail_done), self.assertRaises(UnknownWrite):
149
+ perform_write(self.root, 'save', body, operation)
150
+ self.assertEqual(writes, ['pending', 'done'])
151
+ with patch('resumable_delivery.run_cli') as cli:
152
+ self.assertEqual(recover_write(self.root, 'save'), 91)
153
+ cli.assert_not_called()
154
+
155
+ def test_upload_checkpoint_failure_is_unknown_not_business_failure(self):
156
+ from task_state import atomic_json
157
+ def fail_done(path, value):
158
+ if value['status'] == 'done':
159
+ raise OSError('模拟上传成功记录落盘失败')
160
+ atomic_json(path, value)
161
+ operation = Mock(return_value='uploaded-key')
162
+ with patch('resumable_delivery.atomic_json', side_effect=fail_done), self.assertRaises(UnknownWrite):
163
+ perform_write(self.root, 'upload', {'file': 'final.xlsx'}, operation)
164
+ self.assertEqual(read_intent(self.root, 'upload')['status'], 'pending')
165
+ operation.assert_called_once()
166
+
167
+ def test_unterminated_pagination_does_not_claim_match(self):
168
+ body = ready_body()
169
+ self.pending('save', body)
170
+ with patch('resumable_delivery.run_cli', return_value=page([stored_row(body)])), \
171
+ patch('resumable_delivery._QUERY_LIMIT', 2), patch('resumable_delivery.time.sleep'), \
172
+ self.assertRaises(UnknownWrite):
173
+ recover_write(self.root, 'save')
174
+
175
+ def test_boolean_id_is_not_a_success(self):
176
+ with self.assertRaises(UnknownWrite):
177
+ perform_write(self.root, 'save', ready_body(), Mock(return_value=True))
178
+ self.assertEqual(read_intent(self.root, 'save')['status'], 'pending')
179
+
180
+ def test_identity_guard_runs_before_each_query_page(self):
181
+ body = ready_body()
182
+ self.pending('save', body)
183
+ guard = Mock()
184
+ with patch('resumable_delivery.run_cli', side_effect=[page([stored_row(body)]), page([])]):
185
+ self.assertEqual(recover_write(self.root, 'save', before_request=guard), 91)
186
+ self.assertEqual(guard.call_count, 2)
187
+
188
+ def test_cancel_before_query_does_not_make_http_calls(self):
189
+ self.pending('save', ready_body())
190
+ cancel = threading.Event()
191
+ cancel.set()
192
+ with patch('resumable_delivery.run_cli') as cli, self.assertRaises(InterruptedError):
193
+ recover_write(self.root, 'save', cancel=cancel)
194
+ cli.assert_not_called()
195
+
196
+
197
+ if __name__ == '__main__':
198
+ unittest.main()
@@ -0,0 +1,301 @@
1
+ """分阶段任务的组合回归:真实Excel及提交脚本,业务接口仅用匿名本地替代。"""
2
+ from contextlib import ExitStack, contextmanager
3
+ import json
4
+ from pathlib import Path
5
+ import signal
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ import threading
10
+ import time
11
+ import unittest
12
+ from unittest.mock import patch
13
+ import zipfile
14
+
15
+ sys.dont_write_bytecode = True
16
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
17
+
18
+ from detail_store import write_detail_rows
19
+ from resumable_workflow import run_resumable
20
+ from run_workflow import run_batch, workflow_parser
21
+ from strategy_output import submit_tag_choices
22
+ from resumable_requests import submit_ai_response
23
+ from task_state import atomic_json, read_task
24
+ from test_workflow import small_payload
25
+
26
+ IDENTITY = {'authContextId': 'test-context', 'userType': 'sso', 'gatewayUrl': 'http://127.0.0.1'}
27
+ AI_TEXT = '一页速览\n匿名数据,仅供测试。\n' + '\n'.join(f'【{heading}】\n- 匿名策略内容{index}。\nLearning(待观察):样本不足,继续观察。'
28
+ for index, heading in enumerate(('预算策略', '广告产品策略', '受众策略', '创意策略')))
29
+ DICTIONARIES = {key: [{'id': index, 'name': key + '标签', 'isOther': 0},
30
+ {'id': index + 10, 'name': '其他', 'isOther': 1}]
31
+ for index, key in enumerate(('budget', 'product', 'audience'), 1)}
32
+ SCRIPTS = Path(__file__).resolve().parent
33
+
34
+
35
+ def arguments(root, mode='excel', resume=None, ids=(11,)):
36
+ if resume:
37
+ return workflow_parser().parse_args(['--resume', str(resume)])
38
+ flags = ['--resumable', '--task-root', str(root), '--output-mode', mode,
39
+ '--curr-start', '2026-10-12', '--curr-end', '2026-10-18', '--concurrency', '1']
40
+ for ident in ids:
41
+ flags.extend(['--id', str(ident)])
42
+ if mode == 'excel':
43
+ flags.extend(['--output-dir', str(root / 'output')])
44
+ return workflow_parser().parse_args(flags)
45
+
46
+
47
+ @contextmanager
48
+ def fixture_apis(root, *, pause_fetch=False):
49
+ """替代取数/分析和出站调用,阶段调度、请求、文件生成校验保持真实。"""
50
+ calls = {'fetch': 0, 'analyze': 0, 'upload': 0, 'save': 0, 'saved': []}
51
+ def fetch(args):
52
+ calls['fetch'] += 1
53
+ directory = Path(args.request_dir)
54
+ atomic_json(directory / 'save-context.json', {'bizLineId': args.id,
55
+ 'periodStart': args.curr_start, 'periodEnd': args.curr_end})
56
+ if pause_fetch:
57
+ (root / 'fetch-entered').write_text(str(directory))
58
+ while not (root / 'continue-fetch').exists():
59
+ # 模拟真实取数所检查的工作流取消标记,不运行任何HTTP。
60
+ from cli_json import _local
61
+ if getattr(_local, 'cancel', None) and _local.cancel.is_set():
62
+ raise InterruptedError('模拟取数收到SIGTERM')
63
+ time.sleep(0.01)
64
+ return {'id': args.id, 'name': '匿名业务线', 'adCategoryIndustryIds': [1]}
65
+ def analyze(directory):
66
+ calls['analyze'] += 1
67
+ payload = small_payload()
68
+ details = write_detail_rows(directory, payload.pop('details'))
69
+ payload['detailSource'] = details.reference(directory)
70
+ summary = {'detail_count': len(details), 'curr_total': 10, 'tag_evidence': {}}
71
+ atomic_json(directory / 'payload.json', payload)
72
+ atomic_json(directory / 'summary.json', summary)
73
+ return {**payload, 'details': details}, summary
74
+ def upload(directory, workbook):
75
+ calls['upload'] += 1
76
+ assert Path(workbook).is_file()
77
+ atomic_json(directory / 'upload.out.json', {'ok': True, 'data': {'storageKey': 'sample-key'}})
78
+ return 'sample-key'
79
+ def save(directory, body):
80
+ calls['save'] += 1
81
+ calls['saved'].append(body)
82
+ atomic_json(directory / 'save.out.json', {'ok': True, 'data': 321})
83
+ return 321
84
+ with ExitStack() as stack:
85
+ stack.enter_context(patch('run_workflow.fetch_business', fetch))
86
+ stack.enter_context(patch('run_workflow.summarize_request', analyze))
87
+ stack.enter_context(patch('strategy_output.TagCache.get', return_value=DICTIONARIES))
88
+ stack.enter_context(patch('run_workflow.upload_excel', upload))
89
+ stack.enter_context(patch('run_workflow.save_result', save))
90
+ yield calls
91
+
92
+
93
+ def choices(request):
94
+ return {'selections': {key: [{'choiceKey': item['labels'][0]['choiceKey'], 'reason': '匿名正文动作对应'}]
95
+ for key, item in request['strategies'].items()}}
96
+
97
+
98
+ def submit_event(event, *, script=False):
99
+ packet = json.loads(Path(event['request_path']).read_text())
100
+ if event['event'] == 'tag_review_required':
101
+ body = choices(packet)
102
+ command = [sys.executable, str(SCRIPTS / 'submit_tags.py'), '--request', event['request_path'],
103
+ '--review-id', event['reviewId']]
104
+ direct = lambda: submit_tag_choices(event['request_path'], event['reviewId'], body)
105
+ else:
106
+ body = {'text': AI_TEXT}
107
+ command = [sys.executable, str(SCRIPTS / 'submit_ai_strategy.py'), '--request', event['request_path'],
108
+ '--request-id', event['requestId']]
109
+ direct = lambda: submit_ai_response(event['request_path'], event['requestId'], body)
110
+ if script:
111
+ result = subprocess.run(command, input=json.dumps(body), capture_output=True, text=True, timeout=10)
112
+ if result.returncode:
113
+ raise AssertionError(result.stderr)
114
+ else:
115
+ direct()
116
+
117
+
118
+ class ResumableIntegrationTests(unittest.TestCase):
119
+ def advance(self, args, events=None, cancel=None):
120
+ return run_resumable(args, cancel or threading.Event(), (events if events is not None else []).append,
121
+ identity_check=lambda _: IDENTITY)
122
+
123
+ def test_excel_three_calls_and_submit_scripts_match_legacy_workbook(self):
124
+ with tempfile.TemporaryDirectory() as temporary:
125
+ root = Path(temporary)
126
+ with fixture_apis(root) as calls:
127
+ first = self.advance(arguments(root))
128
+ self.assertFalse(first['complete'])
129
+ self.assertEqual(first['results'][0]['request']['event'], 'tag_review_required')
130
+ task_path = first['taskPath']
131
+ submit_event(first['results'][0]['request'], script=True)
132
+ second = self.advance(arguments(root, resume=task_path))
133
+ self.assertEqual(second['results'][0]['request']['event'], 'ai_strategy_required', second)
134
+ submit_event(second['results'][0]['request'], script=True)
135
+ final = self.advance(arguments(root, resume=task_path))
136
+ self.assertTrue(final['ok'], final)
137
+ self.assertTrue(final['complete'])
138
+ self.assertEqual(calls['fetch'], 1)
139
+ self.assertEqual(calls['analyze'], 1)
140
+ self.assertEqual((calls['upload'], calls['save']), (0, 0))
141
+ output = Path(final['results'][0]['output_path'])
142
+ repeated = self.advance(arguments(root, resume=task_path))
143
+ self.assertEqual(repeated['results'][0]['output_path'], str(output))
144
+ self.assertEqual(len(list((root / 'output').glob('*.xlsx'))), 1)
145
+
146
+ def legacy_progress(event):
147
+ if event.get('event') == 'tag_review_required':
148
+ packet = json.loads(Path(event['request_path']).read_text())
149
+ submit_tag_choices(event['request_path'], event['reviewId'], choices(packet))
150
+ elif event.get('event') == 'ai_strategy_required':
151
+ packet = {key: event[key] for key in ('bizLineId', 'attempt', 'requestId')}
152
+ atomic_json(Path(event['response_path']), {**packet, 'text': AI_TEXT})
153
+ old_args = arguments(root / 'legacy')
154
+ old_args.resumable = False
155
+ legacy = run_batch(old_args, progress=legacy_progress)
156
+ self.assertTrue(legacy['ok'], legacy)
157
+ with zipfile.ZipFile(output) as actual, zipfile.ZipFile(legacy['results'][0]['output_path']) as old:
158
+ self.assertEqual(set(actual.namelist()), set(old.namelist()))
159
+ for name in actual.namelist():
160
+ self.assertEqual(actual.read(name), old.read(name), name)
161
+
162
+ def test_database_saves_once_and_keeps_id_after_repeated_resume(self):
163
+ with tempfile.TemporaryDirectory() as temporary:
164
+ root = Path(temporary)
165
+ with fixture_apis(root) as calls:
166
+ first = self.advance(arguments(root, 'database'))
167
+ submit_event(first['results'][0]['request'])
168
+ second = self.advance(arguments(root, resume=first['taskPath']))
169
+ submit_event(second['results'][0]['request'])
170
+ final = self.advance(arguments(root, resume=first['taskPath']))
171
+ self.assertTrue(final['ok'], final)
172
+ self.assertEqual(final['results'][0]['aiStrategyId'], 321)
173
+ self.assertEqual((calls['upload'], calls['save']), (1, 1))
174
+ self.assertEqual(calls['saved'][0]['fileStorageKey'], 'sample-key')
175
+ again = self.advance(arguments(root, resume=first['taskPath']))
176
+ self.assertTrue(again['ok'], again)
177
+ self.assertEqual((calls['upload'], calls['save']), (1, 1))
178
+ self.assertFalse(list(root.rglob('*.xlsx')))
179
+
180
+ def test_next_day_restarts_data_and_rejects_old_request(self):
181
+ with tempfile.TemporaryDirectory() as temporary:
182
+ root = Path(temporary)
183
+ with fixture_apis(root) as calls, patch('resumable_workflow.shanghai_date', return_value='2026-10-25'):
184
+ first = self.advance(arguments(root))
185
+ old_request = first['results'][0]['request']
186
+ with fixture_apis(root) as next_calls, patch('resumable_workflow.shanghai_date', return_value='2026-10-26'):
187
+ resumed = self.advance(arguments(root, resume=first['taskPath']))
188
+ self.assertEqual(resumed['results'][0]['generation'], 2, resumed)
189
+ self.assertEqual(next_calls['fetch'], 1)
190
+ self.assertNotEqual(resumed['results'][0]['request']['reviewId'], old_request['reviewId'])
191
+ with self.assertRaises((ValueError, FileNotFoundError)):
192
+ submit_event(old_request)
193
+ self.assertEqual(read_task(first['taskPath'])['inputs']['curr_start'], '2026-10-12')
194
+
195
+ def test_sigterm_during_fetch_preserves_task_for_a_new_process(self):
196
+ with tempfile.TemporaryDirectory() as temporary:
197
+ root = Path(temporary)
198
+ code = """import sys,signal,threading,json
199
+ from pathlib import Path
200
+ sys.path.insert(0,sys.argv[1])
201
+ from test_resumable_integration import arguments,fixture_apis,IDENTITY
202
+ from resumable_workflow import run_resumable
203
+ root=Path(sys.argv[2]); cancel=threading.Event()
204
+ signal.signal(signal.SIGTERM,lambda *_:cancel.set())
205
+ with fixture_apis(root,pause_fetch=True) as calls:
206
+ result=run_resumable(arguments(root,'database'),cancel,lambda _:None,identity_check=lambda _:IDENTITY)
207
+ (root/'child-result.json').write_text(json.dumps({'result':result,'calls':calls}))
208
+ """
209
+ proc = subprocess.Popen([sys.executable, '-B', '-c', code, str(SCRIPTS), str(root)],
210
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
211
+ try:
212
+ deadline = time.monotonic() + 8
213
+ while not (root / 'fetch-entered').exists() and time.monotonic() < deadline:
214
+ if proc.poll() is not None:
215
+ self.fail(proc.communicate()[1])
216
+ time.sleep(0.01)
217
+ self.assertTrue((root / 'fetch-entered').exists())
218
+ proc.send_signal(signal.SIGTERM)
219
+ out, error = proc.communicate(timeout=8)
220
+ self.assertEqual(proc.returncode, 0, out + error)
221
+ saved = json.loads((root / 'child-result.json').read_text())
222
+ self.assertEqual(saved['result']['status'], 'interrupted', saved)
223
+ self.assertEqual(saved['calls']['save'], 0)
224
+ task_path = saved['result']['taskPath']
225
+ task = read_task(task_path)
226
+ self.assertTrue(Path(task['lines']['11']['workPath']).is_dir())
227
+ # 原进程已经结束;由当前独立进程继续相同任务。
228
+ with fixture_apis(root) as calls:
229
+ resumed = self.advance(arguments(root, resume=task_path))
230
+ self.assertEqual(resumed['results'][0]['request']['event'], 'tag_review_required', resumed)
231
+ self.assertEqual(calls['fetch'], 1)
232
+ self.assertEqual(calls['save'], 0)
233
+ finally:
234
+ if proc.poll() is None:
235
+ proc.kill()
236
+ proc.communicate(timeout=5)
237
+
238
+ def test_midnight_after_upload_does_not_save_old_data(self):
239
+ with tempfile.TemporaryDirectory() as temporary:
240
+ root = Path(temporary)
241
+ day = {'value': '2026-10-25'}
242
+ with fixture_apis(root) as calls, patch('resumable_workflow.shanghai_date', side_effect=lambda: day['value']):
243
+ first = self.advance(arguments(root, 'database'))
244
+ submit_event(first['results'][0]['request'])
245
+ second = self.advance(arguments(root, resume=first['taskPath']))
246
+ submit_event(second['results'][0]['request'])
247
+ import run_workflow
248
+ upload = run_workflow.upload_excel
249
+ def cross_midnight(directory, workbook):
250
+ value = upload(directory, workbook)
251
+ day['value'] = '2026-10-26'
252
+ return value
253
+ with patch('run_workflow.upload_excel', cross_midnight):
254
+ paused = self.advance(arguments(root, resume=first['taskPath']))
255
+ self.assertFalse(paused['complete'], paused)
256
+ self.assertEqual(calls['upload'], 1)
257
+ self.assertEqual(calls['save'], 0)
258
+ refreshed = self.advance(arguments(root, resume=first['taskPath']))
259
+ self.assertEqual(refreshed['results'][0]['generation'], 2, refreshed)
260
+ self.assertEqual(refreshed['results'][0]['request']['event'], 'tag_review_required')
261
+ self.assertEqual(calls['fetch'], 2)
262
+ self.assertEqual(calls['save'], 0)
263
+
264
+ def test_midnight_after_export_does_not_publish_old_data(self):
265
+ with tempfile.TemporaryDirectory() as temporary:
266
+ root = Path(temporary)
267
+ day = {'value': '2026-10-25'}
268
+ with fixture_apis(root), patch('resumable_workflow.shanghai_date', side_effect=lambda: day['value']):
269
+ first = self.advance(arguments(root))
270
+ submit_event(first['results'][0]['request'])
271
+ second = self.advance(arguments(root, resume=first['taskPath']))
272
+ submit_event(second['results'][0]['request'])
273
+ import run_workflow
274
+ export = run_workflow.write_xlsx
275
+ def cross_midnight(rows, filename, details, **kwargs):
276
+ value = export(rows, filename, details, **kwargs)
277
+ if Path(filename).name == 'final.xlsx':
278
+ day['value'] = '2026-10-26'
279
+ return value
280
+ with patch('run_workflow.write_xlsx', cross_midnight):
281
+ paused = self.advance(arguments(root, resume=first['taskPath']))
282
+ self.assertFalse(paused['complete'], paused)
283
+ self.assertFalse(list((root / 'output').glob('*.xlsx')))
284
+ refreshed = self.advance(arguments(root, resume=first['taskPath']))
285
+ self.assertEqual(refreshed['results'][0]['generation'], 2, refreshed)
286
+ self.assertEqual(refreshed['results'][0]['request']['event'], 'tag_review_required')
287
+
288
+ def test_pending_retains_ai_capacity_across_calls(self):
289
+ with tempfile.TemporaryDirectory() as temporary:
290
+ root = Path(temporary)
291
+ with fixture_apis(root) as calls:
292
+ first = self.advance(arguments(root, ids=(11, 12, 13)))
293
+ self.assertEqual(calls['fetch'], 2)
294
+ self.assertEqual(sum(x['request'] is not None and x['request']['event'] == 'tag_review_required'
295
+ for x in first['results']), 1)
296
+ self.advance(arguments(root, resume=first['taskPath']))
297
+ self.assertEqual(calls['fetch'], 2)
298
+
299
+
300
+ if __name__ == '__main__':
301
+ unittest.main()