@xioflow/kernel 0.1.5 → 0.3.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 (46) hide show
  1. package/README.md +50 -5
  2. package/dist/domain.d.ts +60 -6
  3. package/dist/domain.d.ts.map +1 -1
  4. package/dist/domain.js +405 -24
  5. package/dist/domain.js.map +1 -1
  6. package/dist/driver/node-driver.d.ts +10 -24
  7. package/dist/driver/node-driver.d.ts.map +1 -1
  8. package/dist/driver/node-driver.js +201 -280
  9. package/dist/driver/node-driver.js.map +1 -1
  10. package/dist/driver/process-facts.d.ts +25 -0
  11. package/dist/driver/process-facts.d.ts.map +1 -0
  12. package/dist/driver/process-facts.js +164 -0
  13. package/dist/driver/process-facts.js.map +1 -0
  14. package/dist/driver/sampler.d.ts +51 -0
  15. package/dist/driver/sampler.d.ts.map +1 -0
  16. package/dist/driver/sampler.js +224 -0
  17. package/dist/driver/sampler.js.map +1 -0
  18. package/dist/driver/types.d.ts +21 -1
  19. package/dist/driver/types.d.ts.map +1 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/quick-run.d.ts +27 -0
  25. package/dist/quick-run.d.ts.map +1 -0
  26. package/dist/quick-run.js +82 -0
  27. package/dist/quick-run.js.map +1 -0
  28. package/dist/recovery/engine.d.ts.map +1 -1
  29. package/dist/recovery/engine.js +103 -19
  30. package/dist/recovery/engine.js.map +1 -1
  31. package/dist/store/sqlite.d.ts +15 -0
  32. package/dist/store/sqlite.d.ts.map +1 -1
  33. package/dist/store/sqlite.js +210 -12
  34. package/dist/store/sqlite.js.map +1 -1
  35. package/dist/supervisor/supervisor.d.ts +16 -0
  36. package/dist/supervisor/supervisor.d.ts.map +1 -1
  37. package/dist/supervisor/supervisor.js +820 -308
  38. package/dist/supervisor/supervisor.js.map +1 -1
  39. package/dist/testing/contract-suite.d.ts.map +1 -1
  40. package/dist/testing/contract-suite.js +479 -11
  41. package/dist/testing/contract-suite.js.map +1 -1
  42. package/dist/types.d.ts +54 -0
  43. package/dist/types.d.ts.map +1 -1
  44. package/dist/types.js +59 -0
  45. package/dist/types.js.map +1 -1
  46. package/package.json +2 -2
@@ -2,7 +2,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import crypto from 'node:crypto';
5
- import { ExecutionDomain, SqliteStore, ProcessSupervisor, RecoveryEngine, ResourceConflictError, UnsupportedCapabilityError, EpochFencedError, } from '../index.js';
5
+ import { spawn } from 'node:child_process';
6
+ import { ExecutionDomain, SqliteStore, ProcessSupervisor, RecoveryEngine, ResourceConflictError, UnsupportedCapabilityError, EpochFencedError, OperationNotActiveError, DuplicateOperationError, OperationIdConflictError, RecoveryRequiredError, computeInputFingerprint, } from '../index.js';
6
7
  function ensureTaskAndRun(domain, taskId, runId) {
7
8
  const store = domain.getStore();
8
9
  if (!store.getTask(taskId)) {
@@ -25,7 +26,7 @@ function ensureTaskAndRun(domain, taskId, runId) {
25
26
  }
26
27
  }
27
28
  export function defineContractTestSuite(consumerName, createContext) {
28
- describe(`18-Item Consistency Contract Suite: [${consumerName}]`, () => {
29
+ describe(`Consistency Contract Suite: [${consumerName}]`, () => {
29
30
  let ctx;
30
31
  beforeEach(async () => {
31
32
  ctx = await createContext();
@@ -103,7 +104,7 @@ export function defineContractTestSuite(consumerName, createContext) {
103
104
  return 'cannot_determine';
104
105
  },
105
106
  async terminate() {
106
- return { stopped: false, scope: 'unknown', errorDetails: 'Cannot confirm exit' };
107
+ return { stopped: 'cannot_determine', scope: 'unknown', errorDetails: 'Cannot confirm exit' };
107
108
  },
108
109
  };
109
110
  const customSupervisor = new ProcessSupervisor(domain, mockUnconfirmedDriver);
@@ -120,7 +121,7 @@ export function defineContractTestSuite(consumerName, createContext) {
120
121
  });
121
122
  await new Promise((r) => setTimeout(r, 100));
122
123
  const cancelRes = await customSupervisor.cancelOperation('op-c3-stubborn');
123
- expect(cancelRes.stopped).toBe(false);
124
+ expect(cancelRes.stopped).toBe('cannot_determine');
124
125
  // 核心断言:未确认停止前,排他锁绝不释放!
125
126
  expect(domain.isResourceLocked('res:c3-lock')).toBe(true);
126
127
  const op = domain.getStore().getOperation('op-c3-stubborn');
@@ -148,11 +149,11 @@ export function defineContractTestSuite(consumerName, createContext) {
148
149
  }
149
150
  // 2. 带排队等待异步放行
150
151
  setTimeout(() => {
151
- domain.releaseResources('op-owner-A', ['res:exclusive-port']);
152
+ domain.internalReleaseResources('op-owner-A', ['res:exclusive-port']);
152
153
  }, 50);
153
154
  await domain.allocateResourcesWithWait('op-requester-B', ['res:exclusive-port'], 300);
154
155
  expect(domain.getResourceOwner('res:exclusive-port')).toBe('op-requester-B');
155
- domain.releaseResources('op-requester-B');
156
+ domain.internalReleaseResources('op-requester-B');
156
157
  });
157
158
  it('契约 5: 不确定副作用防重放,重启恢复时阻断自动重试并保留隔离', async () => {
158
159
  const { domain, driver } = ctx;
@@ -353,7 +354,7 @@ export function defineContractTestSuite(consumerName, createContext) {
353
354
  expect(grandchildAliveBefore).toBe(true);
354
355
  // 执行整组停止流水线
355
356
  const stopRes = await supervisor.cancelOperation('op-c9-group', 1500);
356
- expect(stopRes.stopped).toBe(true);
357
+ expect(stopRes.stopped).toBe('confirmed_stopped');
357
358
  expect(domain.isResourceLocked('res:c9-group')).toBe(false);
358
359
  // 核心真实断言:整组 kill(-pgid) 广播后,同组孙进程必须确定性死亡,绝不泄漏为 PID 1 孤儿!
359
360
  let grandchildAliveAfter = true;
@@ -422,8 +423,8 @@ export function defineContractTestSuite(consumerName, createContext) {
422
423
  expect(escapedAliveBefore).toBe(true);
423
424
  // 调用停止流水线:NodePlatformDriver 真实后代树扫描探测到逃逸残留
424
425
  const cancelRes = await supervisor.cancelOperation('op-c10-escape', 1000);
425
- // 真实上报 stopped: false,且 residualPids 包含逃逸孤儿 PID
426
- expect(cancelRes.stopped).toBe(false);
426
+ // 真实上报 stopped: cannot_determine,且 residualPids 包含逃逸孤儿 PID
427
+ expect(cancelRes.stopped).toBe('cannot_determine');
427
428
  expect(cancelRes.residualPids).toBeDefined();
428
429
  expect(cancelRes.residualPids).toContain(escapedPid);
429
430
  // 核心架构红线:驱动无法确认停止时,必须转入 indeterminate,绝对保留隔离屏障与排他锁!
@@ -538,11 +539,11 @@ export function defineContractTestSuite(consumerName, createContext) {
538
539
  domain.allocateResources('op-second', ['res:op2']);
539
540
  }).toThrowError(ResourceConflictError);
540
541
  setTimeout(() => {
541
- domain.releaseResources('op-first');
542
+ domain.internalReleaseResources('op-first');
542
543
  }, 60);
543
544
  await domain.allocateResourcesWithWait('op-second', ['res:op2'], 300);
544
545
  expect(domain.getResourceOwner('res:op2')).toBe('op-second');
545
- domain.releaseResources('op-second');
546
+ domain.internalReleaseResources('op-second');
546
547
  });
547
548
  it('契约 15: 大输出流式落盘转储与 fsync 校验 (Spill to Artifacts)', async () => {
548
549
  const { supervisor, domain, tempDir } = ctx;
@@ -695,6 +696,473 @@ export function defineContractTestSuite(consumerName, createContext) {
695
696
  expect(throwing.stdout).toBe('captured-anyway');
696
697
  expect(throwing.streamCallbackError).toBe('consumer projection failed');
697
698
  });
699
+ it('契约 19: 超时 + 逃逸后代持有管道,操作在有界时间内返回 indeterminate,不挂到逃逸进程退出', async () => {
700
+ const { supervisor, domain, tempDir } = ctx;
701
+ ensureTaskAndRun(domain, 'task-c19', 'run-c19');
702
+ const subPidFile = path.join(tempDir, 'sub-c19.pid');
703
+ const runnerScript = `
704
+ const { spawn } = require('node:child_process');
705
+ const fs = require('node:fs');
706
+ const sub = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 8000);'], {
707
+ detached: true,
708
+ stdio: ['ignore', 'inherit', 'inherit']
709
+ });
710
+ fs.writeFileSync(${JSON.stringify(subPidFile)}, String(sub.pid));
711
+ setInterval(() => {}, 1000);
712
+ `;
713
+ const startedAt = Date.now();
714
+ const result = await supervisor.executeProcess({
715
+ runId: 'run-c19',
716
+ opId: 'op-c19-timeout-bounded',
717
+ name: 'timeout-bounded-op',
718
+ command: {
719
+ execPath: process.execPath,
720
+ args: ['-e', runnerScript],
721
+ cwd: tempDir,
722
+ },
723
+ requiredResources: ['res:c19-bounded'],
724
+ timeoutMs: 600,
725
+ drainTimeoutMs: 300,
726
+ });
727
+ const duration = Date.now() - startedAt;
728
+ expect(duration).toBeLessThan(3500);
729
+ expect(result.status).toBe('indeterminate');
730
+ expect(domain.isResourceLocked('res:c19-bounded')).toBe(true);
731
+ // 清理逃逸孙进程
732
+ if (fs.existsSync(subPidFile)) {
733
+ try {
734
+ const pid = parseInt(fs.readFileSync(subPidFile, 'utf8'), 10);
735
+ process.kill(pid, 'SIGKILL');
736
+ }
737
+ catch { }
738
+ }
739
+ }, 15_000);
740
+ it('契约 20: 停止不存在或已终结的操作返回显式 OperationNotActiveError', async () => {
741
+ const { supervisor, domain, tempDir } = ctx;
742
+ ensureTaskAndRun(domain, 'task-c20', 'run-c20');
743
+ // 1. 不存在的操作
744
+ await expect(supervisor.cancelOperation('op-c20-nonexistent')).rejects.toSatisfy((err) => {
745
+ expect(err).toBeInstanceOf(OperationNotActiveError);
746
+ expect(err.reason).toBe('not_found');
747
+ return true;
748
+ });
749
+ // 2. 已终结的操作
750
+ const completedOpId = 'op-c20-completed';
751
+ await supervisor.executeProcess({
752
+ runId: 'run-c20',
753
+ opId: completedOpId,
754
+ name: 'completed-op',
755
+ command: {
756
+ execPath: process.execPath,
757
+ args: ['-e', 'process.exit(0);'],
758
+ cwd: tempDir,
759
+ },
760
+ requiredResources: [],
761
+ });
762
+ await expect(supervisor.cancelOperation(completedOpId)).rejects.toSatisfy((err) => {
763
+ expect(err).toBeInstanceOf(OperationNotActiveError);
764
+ expect(err.reason).toBe('already_completed');
765
+ return true;
766
+ });
767
+ });
768
+ it('契约 21: 僵尸 leader + 存活后代:恢复按组清场后才结清', async () => {
769
+ const { domain, driver, tempDir } = ctx;
770
+ const opId = 'op-c21-zombie';
771
+ const runId = 'run-c21';
772
+ ensureTaskAndRun(domain, 'task-c21', runId);
773
+ domain.registerOperationIntent({
774
+ id: opId,
775
+ runId,
776
+ kind: 'process',
777
+ name: 'process:zombie-with-orphan',
778
+ inputFingerprint: 'fp-c21',
779
+ requiredResources: ['res:c21'],
780
+ status: 'intent_registered',
781
+ });
782
+ const descendant = spawn(process.execPath, ['-e', 'process.on("SIGTERM",()=>{}); setInterval(()=>{},1000)'], { detached: true, stdio: ['ignore', 'inherit', 'inherit'] });
783
+ const descendantPid = descendant.pid;
784
+ descendant.unref();
785
+ domain.getStore().updateOperationStatus(opId, 'active', {
786
+ pid: descendantPid,
787
+ pgid: descendantPid,
788
+ spawnTime: new Date().toISOString(),
789
+ commandFingerprint: `${process.execPath}:-e`,
790
+ });
791
+ domain.getStore().updateOperationStatus(opId, 'stopping');
792
+ const stored = domain.getStore().getOperation(opId);
793
+ domain.getStore().updateOperationStatus(opId, 'active', {
794
+ ...stored.processIdentity,
795
+ pid: 2_147_483_000,
796
+ });
797
+ try {
798
+ const report = await new RecoveryEngine(domain, driver).recover();
799
+ expect(report.recoveredOperations).toHaveLength(1);
800
+ expect(report.recoveredOperations[0].action).toBe('marked_dead');
801
+ expect(report.recoveredOperations[0].resourcesReleased).toBe(true);
802
+ expect(domain.isResourceLocked('res:c21')).toBe(false);
803
+ const result = domain.getStore().getOperation(opId).result;
804
+ expect(result.status).toBe('failed');
805
+ expect(result.kind === 'process' ? result.stderr : '').toContain('reaped during recovery');
806
+ }
807
+ finally {
808
+ try {
809
+ process.kill(-descendantPid, 'SIGKILL');
810
+ }
811
+ catch { }
812
+ }
813
+ }, 20_000);
814
+ it('契约 22: 重复 opId 提交抛出 DuplicateOperationError,原操作租约与可取消性不受影响', async () => {
815
+ const { supervisor, domain, tempDir } = ctx;
816
+ ensureTaskAndRun(domain, 'task-c22', 'run-c22');
817
+ const opId = 'op-c22-duplicate';
818
+ const execPromise = supervisor.executeProcess({
819
+ runId: 'run-c22',
820
+ opId,
821
+ name: 'sleep-op',
822
+ command: {
823
+ execPath: process.execPath,
824
+ args: ['-e', 'setInterval(() => {}, 1000);'],
825
+ cwd: tempDir,
826
+ },
827
+ requiredResources: ['res:c22'],
828
+ });
829
+ await new Promise((r) => setTimeout(r, 100));
830
+ // 重复提交相同 opId 必须抛出 DuplicateOperationError
831
+ await expect(supervisor.executeProcess({
832
+ runId: 'run-c22',
833
+ opId,
834
+ name: 'duplicate-op',
835
+ command: {
836
+ execPath: process.execPath,
837
+ args: ['-e', 'process.exit(0);'],
838
+ cwd: tempDir,
839
+ },
840
+ requiredResources: ['res:c22'],
841
+ })).rejects.toThrow(DuplicateOperationError);
842
+ // 原操作排他租约未被破坏
843
+ expect(domain.isResourceLocked('res:c22')).toBe(true);
844
+ // 原操作仍可成功取消
845
+ const stopRes = await supervisor.cancelOperation(opId, 1000);
846
+ expect(stopRes.stopped).toBe('confirmed_stopped');
847
+ const opResult = await execPromise;
848
+ expect(opResult.status).toBe('cancelled');
849
+ });
850
+ it('契约 23: 恢复后 Run 状态收敛,无未终结操作时 Run 不停留在 running', async () => {
851
+ const { domain, driver } = ctx;
852
+ const taskId = 'task-c23';
853
+ const runId = 'run-c23';
854
+ ensureTaskAndRun(domain, taskId, runId);
855
+ domain.registerOperationIntent({
856
+ id: 'op-c23-1',
857
+ runId,
858
+ kind: 'process',
859
+ name: 'clean-crashed-op',
860
+ inputFingerprint: 'f'.repeat(64),
861
+ requiredResources: ['res:c23-clean'],
862
+ status: 'intent_registered',
863
+ });
864
+ const recovery = new RecoveryEngine(domain, driver);
865
+ await recovery.recover();
866
+ const runAfter = domain.getStore().getRun(runId);
867
+ expect(runAfter?.status).toBe('failed');
868
+ expect(runAfter?.terminationReason).toBe('crash_detected');
869
+ });
870
+ it('契约 24: 人工裁决受审计出口:仅对 indeterminate 生效,写 journal 并受代际栅栏约束', async () => {
871
+ const { domain } = ctx;
872
+ const opId = 'op-c24-indet';
873
+ const runId = 'run-c24';
874
+ ensureTaskAndRun(domain, 'task-c24', runId);
875
+ domain.registerOperationIntent({
876
+ id: opId,
877
+ runId,
878
+ kind: 'process',
879
+ name: 'indet-op',
880
+ inputFingerprint: 'f'.repeat(64),
881
+ requiredResources: ['res:c24'],
882
+ status: 'intent_registered',
883
+ });
884
+ // 写入 indeterminate 结果
885
+ domain.getStore().recordOperationResult(opId, {
886
+ kind: 'indeterminate',
887
+ status: 'indeterminate',
888
+ reason: 'Process cannot be determined',
889
+ recoveryGuidance: 'Check manually',
890
+ durationMs: 10,
891
+ completedAt: new Date().toISOString(),
892
+ }, false // 不释放租约
893
+ );
894
+ expect(domain.isResourceLocked('res:c24')).toBe(true);
895
+ // 人工裁决 confirmed_stopped 成功释放租约
896
+ const record = await domain.adjudicate(opId, 'confirmed_stopped', 'admin-user', 'Manual verified dead');
897
+ expect(record.verdict).toBe('confirmed_stopped');
898
+ expect(domain.isResourceLocked('res:c24')).toBe(false);
899
+ const events = domain.getStore().getJournalEvents(domain.domainId);
900
+ const adjEvent = events.find((e) => e.type === 'OPERATION_ADJUDICATED' && e.operationId === opId);
901
+ expect(adjEvent).toBeDefined();
902
+ });
903
+ it('契约 25: 内存保留 Head + Tail,截断点落在 UTF-8 边界', async () => {
904
+ const { supervisor, domain, tempDir } = ctx;
905
+ ensureTaskAndRun(domain, 'task-c25', 'run-c25');
906
+ const result = await supervisor.executeProcess({
907
+ runId: 'run-c25',
908
+ opId: 'op-c25-head-tail',
909
+ name: 'head-tail-op',
910
+ command: {
911
+ execPath: process.execPath,
912
+ args: [
913
+ '-e',
914
+ 'process.stdout.write("HEAD-START-12345\\n" + "x".repeat(120 * 1024) + "\\nTAIL-END-67890");',
915
+ ],
916
+ cwd: tempDir,
917
+ },
918
+ requiredResources: [],
919
+ maxOutputBytes: 10 * 1024,
920
+ });
921
+ expect(result.status).toBe('succeeded');
922
+ expect(result.isTruncated).toBe(true);
923
+ expect(result.stdout).toContain('HEAD-START-12345');
924
+ expect(result.stdout).toContain('TAIL-END-67890');
925
+ expect(result.stdout).toContain('[... truncated');
926
+ });
927
+ it('契约 26: 转储失败可见:spillError 出现时不返回引用', async () => {
928
+ const { supervisor, domain, tempDir } = ctx;
929
+ ensureTaskAndRun(domain, 'task-c26', 'run-c26');
930
+ const nonWritablePath = path.join(tempDir, 'non-writable-file');
931
+ fs.writeFileSync(nonWritablePath, 'block');
932
+ const result = await supervisor.executeProcess({
933
+ runId: 'run-c26',
934
+ opId: 'op-c26-spill-err',
935
+ name: 'spill-err-op',
936
+ command: {
937
+ execPath: process.execPath,
938
+ args: ['-e', 'process.stdout.write("hello-spill-fail");'],
939
+ cwd: tempDir,
940
+ },
941
+ requiredResources: [],
942
+ artifactsDir: nonWritablePath,
943
+ });
944
+ expect(result.status).toBe('succeeded');
945
+ expect(result.spillError).toBeDefined();
946
+ expect(result.outputRef).toBeUndefined();
947
+ expect(result.stdout).toBe('hello-spill-fail');
948
+ });
949
+ it('契约 27: 未截断转储在结清时删除;pruneArtifacts 拒绝回收未终结 / 未裁决 op 的产物', async () => {
950
+ const { supervisor, domain, tempDir } = ctx;
951
+ ensureTaskAndRun(domain, 'task-c27', 'run-c27');
952
+ const artifactsDir = path.join(tempDir, 'artifacts');
953
+ // 1. 未截断转储在结清时自动删除
954
+ const untruncated = await supervisor.executeProcess({
955
+ runId: 'run-c27',
956
+ opId: 'op-c27-untruncated',
957
+ name: 'untruncated-op',
958
+ command: {
959
+ execPath: process.execPath,
960
+ args: ['-e', 'process.stdout.write("short text");'],
961
+ cwd: tempDir,
962
+ },
963
+ requiredResources: [],
964
+ artifactsDir,
965
+ });
966
+ expect(untruncated.status).toBe('succeeded');
967
+ expect(untruncated.isTruncated).toBe(false);
968
+ expect(untruncated.outputRef).toBeUndefined();
969
+ const files = fs.existsSync(artifactsDir) ? fs.readdirSync(artifactsDir) : [];
970
+ expect(files.filter((f) => f.includes('op-c27-untruncated'))).toHaveLength(0);
971
+ // 2. pruneArtifacts 拒绝回收未终结 / 未裁决 op 的产物
972
+ domain.registerOperationIntent({
973
+ id: 'op-c27-active',
974
+ runId: 'run-c27',
975
+ kind: 'process',
976
+ name: 'active-op',
977
+ inputFingerprint: 'f'.repeat(64),
978
+ requiredResources: [],
979
+ status: 'intent_registered',
980
+ });
981
+ fs.mkdirSync(artifactsDir, { recursive: true });
982
+ const activeFile = path.join(artifactsDir, 'op-c27-active-stdout.log');
983
+ fs.writeFileSync(activeFile, 'active artifact');
984
+ const pruneResult = domain.pruneArtifacts();
985
+ expect(pruneResult.retained).toContain(activeFile);
986
+ expect(fs.existsSync(activeFile)).toBe(true);
987
+ });
988
+ it('契约 45: 同 opId 同指纹重放返回已记录结果,不产生新进程,写 OPERATION_REPLAYED', async () => {
989
+ const { supervisor, domain, tempDir } = ctx;
990
+ ensureTaskAndRun(domain, 'task-c45', 'run-c45');
991
+ const counterFile = path.join(tempDir, 'counter-c45.log');
992
+ const options = {
993
+ runId: 'run-c45',
994
+ opId: 'op-c45-recorded',
995
+ name: 'op-c45',
996
+ requiredResources: [],
997
+ command: {
998
+ execPath: process.execPath,
999
+ args: ['-e', `require('fs').appendFileSync(process.argv[1], '1\\n'); console.log('c45-out');`, counterFile],
1000
+ cwd: tempDir,
1001
+ },
1002
+ };
1003
+ const res1 = await supervisor.executeProcess(options);
1004
+ expect(res1.status).toBe('succeeded');
1005
+ expect(res1.replayed).toBeUndefined();
1006
+ expect(res1.stdout).toContain('c45-out');
1007
+ const res2 = await supervisor.executeProcess(options);
1008
+ expect(res2.status).toBe('succeeded');
1009
+ expect(res2.replayed).toBe(true);
1010
+ expect(res2.stdout).toContain('c45-out');
1011
+ // 验证副作用仅发生 1 次
1012
+ const lines = fs.readFileSync(counterFile, 'utf8').trim().split('\n');
1013
+ expect(lines.length).toBe(1);
1014
+ // journal 记录 OPERATION_REPLAYED
1015
+ const events = domain.getStore().getJournalEvents(domain.domainId);
1016
+ const replayEvt = events.find((e) => e.type === 'OPERATION_REPLAYED' && e.operationId === 'op-c45-recorded');
1017
+ expect(replayEvt).toBeDefined();
1018
+ expect(replayEvt?.payload.mode).toBe('recorded');
1019
+ });
1020
+ it('契约 46: 同 opId 不同指纹 => OperationIdConflictError,不改动已有 op 的任何事实', async () => {
1021
+ const { supervisor, domain, tempDir } = ctx;
1022
+ ensureTaskAndRun(domain, 'task-c46', 'run-c46');
1023
+ const options1 = {
1024
+ runId: 'run-c46',
1025
+ opId: 'op-c46-conflict',
1026
+ name: 'op-c46-orig',
1027
+ requiredResources: [],
1028
+ command: {
1029
+ execPath: process.execPath,
1030
+ args: ['-e', 'console.log("c46-orig");'],
1031
+ cwd: tempDir,
1032
+ },
1033
+ };
1034
+ const res1 = await supervisor.executeProcess(options1);
1035
+ expect(res1.status).toBe('succeeded');
1036
+ const options2 = {
1037
+ runId: 'run-c46',
1038
+ opId: 'op-c46-conflict',
1039
+ name: 'op-c46-conflict',
1040
+ requiredResources: [],
1041
+ command: {
1042
+ execPath: process.execPath,
1043
+ args: ['-e', 'console.log("c46-diff");'], // 不同 args
1044
+ cwd: tempDir,
1045
+ },
1046
+ };
1047
+ await expect(supervisor.executeProcess(options2)).rejects.toThrow(OperationIdConflictError);
1048
+ const op = domain.getStore().getOperation('op-c46-conflict');
1049
+ expect(op?.status).toBe('done');
1050
+ expect(op?.result?.status).toBe('succeeded');
1051
+ });
1052
+ it('契约 47: 重放命中在飞 op => 两个调用拿到同一结果,进程只启动一次', async () => {
1053
+ const { supervisor, domain, tempDir } = ctx;
1054
+ ensureTaskAndRun(domain, 'task-c47', 'run-c47');
1055
+ const counterFile = path.join(tempDir, 'counter-c47.log');
1056
+ const options = {
1057
+ runId: 'run-c47',
1058
+ opId: 'op-c47-inflight',
1059
+ name: 'op-c47',
1060
+ requiredResources: [],
1061
+ command: {
1062
+ execPath: process.execPath,
1063
+ args: ['-e', `require('fs').appendFileSync(process.argv[1], '1\\n'); setTimeout(() => console.log('c47-out'), 300);`, counterFile],
1064
+ cwd: tempDir,
1065
+ },
1066
+ };
1067
+ const [res1, res2] = await Promise.all([
1068
+ supervisor.executeProcess(options),
1069
+ supervisor.executeProcess(options),
1070
+ ]);
1071
+ expect(res1.status).toBe('succeeded');
1072
+ expect(res2.status).toBe('succeeded');
1073
+ expect(res2.replayed).toBe(true);
1074
+ const lines = fs.readFileSync(counterFile, 'utf8').trim().split('\n');
1075
+ expect(lines.length).toBe(1);
1076
+ const events = domain.getStore().getJournalEvents(domain.domainId);
1077
+ const replayEvt = events.find((e) => e.type === 'OPERATION_REPLAYED' && e.operationId === 'op-c47-inflight');
1078
+ expect(replayEvt?.payload.mode).toBe('joined');
1079
+ });
1080
+ it('契约 48: 重放命中 indeterminate => 原样返回,不重跑,租约保持', async () => {
1081
+ const { supervisor, domain, tempDir } = ctx;
1082
+ ensureTaskAndRun(domain, 'task-c48', 'run-c48');
1083
+ const opId = 'op-c48-indet';
1084
+ const store = domain.getStore();
1085
+ const options = {
1086
+ runId: 'run-c48',
1087
+ opId,
1088
+ name: 'test-indet',
1089
+ requiredResources: ['res:c48'],
1090
+ command: {
1091
+ execPath: process.execPath,
1092
+ args: ['-e', 'process.exit(0)'],
1093
+ cwd: tempDir,
1094
+ },
1095
+ };
1096
+ store.registerOperationIntent({
1097
+ id: opId,
1098
+ runId: 'run-c48',
1099
+ kind: 'process',
1100
+ name: 'test-indet',
1101
+ inputFingerprint: computeInputFingerprint(options),
1102
+ requiredResources: ['res:c48'],
1103
+ status: 'pending',
1104
+ }, domain.domainId);
1105
+ const indetResult = {
1106
+ kind: 'process',
1107
+ status: 'indeterminate',
1108
+ exitCode: null,
1109
+ signal: null,
1110
+ stdout: '',
1111
+ stderr: 'indeterminate residual',
1112
+ isTruncated: false,
1113
+ identityVerification: 'cannot_determine',
1114
+ durationMs: 50,
1115
+ completedAt: new Date().toISOString(),
1116
+ };
1117
+ store.recordOperationResult(opId, indetResult, false);
1118
+ const res = await supervisor.executeProcess(options);
1119
+ expect(res.status).toBe('indeterminate');
1120
+ expect(res.replayed).toBe(true);
1121
+ const leases = store.getPersistedResourceLeases(domain.domainId);
1122
+ expect(leases.some((l) => l.operationId === opId && l.resourceId === 'res:c48')).toBe(true);
1123
+ });
1124
+ it('契约 49: 有意图无身份的崩溃现场:未恢复时提交抛 RecoveryRequiredError;恢复后结清重放命中已记录事实', async () => {
1125
+ const { supervisor, domain, driver, tempDir } = ctx;
1126
+ ensureTaskAndRun(domain, 'task-c49', 'run-c49');
1127
+ const opId = 'op-c49-unrecovered';
1128
+ const store = domain.getStore();
1129
+ const options = {
1130
+ runId: 'run-c49',
1131
+ opId,
1132
+ name: 'test-unrecovered',
1133
+ requiredResources: [],
1134
+ command: {
1135
+ execPath: process.execPath,
1136
+ args: ['-e', 'console.log(1)'],
1137
+ cwd: tempDir,
1138
+ },
1139
+ };
1140
+ // 构造崩溃残留:intent_registered 且不在内存活跃表中
1141
+ store.registerOperationIntent({
1142
+ id: opId,
1143
+ runId: 'run-c49',
1144
+ kind: 'process',
1145
+ name: 'test-unrecovered',
1146
+ inputFingerprint: computeInputFingerprint(options),
1147
+ requiredResources: [],
1148
+ status: 'intent_registered',
1149
+ }, domain.domainId);
1150
+ // 未调用 recover() 前调用,抛 RecoveryRequiredError
1151
+ await expect(supervisor.executeProcess(options)).rejects.toThrow(RecoveryRequiredError);
1152
+ // 执行崩溃恢复
1153
+ const recoveryEngine = new RecoveryEngine(domain, driver);
1154
+ await recoveryEngine.recover();
1155
+ // 恢复后该意图被收敛为 failed(never spawned),新 Run 再次发起重放应命中已记录结果
1156
+ ensureTaskAndRun(domain, 'task-c49', 'run-c49-new');
1157
+ const replayOptions = {
1158
+ ...options,
1159
+ runId: 'run-c49-new',
1160
+ };
1161
+ const res = await supervisor.executeProcess(replayOptions);
1162
+ expect(res.status).toBe('failed');
1163
+ expect(res.replayed).toBe(true);
1164
+ expect(res.runId).toBe('run-c49');
1165
+ });
698
1166
  });
699
1167
  }
700
1168
  //# sourceMappingURL=contract-suite.js.map