open-multi-agent-kit 0.90.9 → 0.92.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 (131) hide show
  1. package/CHANGELOG.md +39 -4
  2. package/README.md +14 -1
  3. package/dist/core/agent-session.d.ts +27 -0
  4. package/dist/core/agent-session.d.ts.map +1 -1
  5. package/dist/core/agent-session.js +196 -32
  6. package/dist/core/agent-session.js.map +1 -1
  7. package/dist/core/command-safety.d.ts.map +1 -1
  8. package/dist/core/command-safety.js +0 -58
  9. package/dist/core/command-safety.js.map +1 -1
  10. package/dist/core/compaction/transaction.d.ts.map +1 -1
  11. package/dist/core/compaction/transaction.js +7 -3
  12. package/dist/core/compaction/transaction.js.map +1 -1
  13. package/dist/core/context-budget-headroom-candidates.d.ts.map +1 -1
  14. package/dist/core/context-budget-headroom-candidates.js +21 -11
  15. package/dist/core/context-budget-headroom-candidates.js.map +1 -1
  16. package/dist/core/context-budget-system-prompt-items.d.ts +1 -0
  17. package/dist/core/context-budget-system-prompt-items.d.ts.map +1 -1
  18. package/dist/core/context-budget-system-prompt-items.js +7 -8
  19. package/dist/core/context-budget-system-prompt-items.js.map +1 -1
  20. package/dist/core/footer-data-provider.d.ts +7 -1
  21. package/dist/core/footer-data-provider.d.ts.map +1 -1
  22. package/dist/core/footer-data-provider.js +12 -0
  23. package/dist/core/footer-data-provider.js.map +1 -1
  24. package/dist/core/index.d.ts +1 -0
  25. package/dist/core/index.d.ts.map +1 -1
  26. package/dist/core/index.js +1 -0
  27. package/dist/core/index.js.map +1 -1
  28. package/dist/core/loadouts.d.ts.map +1 -1
  29. package/dist/core/loadouts.js +27 -48
  30. package/dist/core/loadouts.js.map +1 -1
  31. package/dist/core/metrics-sampler.d.ts +20 -0
  32. package/dist/core/metrics-sampler.d.ts.map +1 -1
  33. package/dist/core/metrics-sampler.js +52 -0
  34. package/dist/core/metrics-sampler.js.map +1 -1
  35. package/dist/core/model-registry.d.ts.map +1 -1
  36. package/dist/core/model-registry.js +4 -1
  37. package/dist/core/model-registry.js.map +1 -1
  38. package/dist/core/model-resolver.d.ts.map +1 -1
  39. package/dist/core/model-resolver.js +15 -6
  40. package/dist/core/model-resolver.js.map +1 -1
  41. package/dist/core/provider-resilience.d.ts +51 -0
  42. package/dist/core/provider-resilience.d.ts.map +1 -0
  43. package/dist/core/provider-resilience.js +86 -0
  44. package/dist/core/provider-resilience.js.map +1 -0
  45. package/dist/core/run-journal.d.ts.map +1 -1
  46. package/dist/core/run-journal.js +14 -22
  47. package/dist/core/run-journal.js.map +1 -1
  48. package/dist/core/session-termination.d.ts +8 -2
  49. package/dist/core/session-termination.d.ts.map +1 -1
  50. package/dist/core/session-termination.js +45 -3
  51. package/dist/core/session-termination.js.map +1 -1
  52. package/dist/core/settings-manager.d.ts +30 -0
  53. package/dist/core/settings-manager.d.ts.map +1 -1
  54. package/dist/core/settings-manager.js +17 -0
  55. package/dist/core/settings-manager.js.map +1 -1
  56. package/dist/core/system-prompt.d.ts.map +1 -1
  57. package/dist/core/system-prompt.js +35 -30
  58. package/dist/core/system-prompt.js.map +1 -1
  59. package/dist/core/tools/grep.d.ts.map +1 -1
  60. package/dist/core/tools/grep.js +47 -0
  61. package/dist/core/tools/grep.js.map +1 -1
  62. package/dist/core/tools/omp-pure-seams.d.ts +41 -0
  63. package/dist/core/tools/omp-pure-seams.d.ts.map +1 -0
  64. package/dist/core/tools/omp-pure-seams.js +142 -0
  65. package/dist/core/tools/omp-pure-seams.js.map +1 -0
  66. package/dist/core/tools/omp-seam-runtime.d.ts +100 -0
  67. package/dist/core/tools/omp-seam-runtime.d.ts.map +1 -0
  68. package/dist/core/tools/omp-seam-runtime.js +40 -0
  69. package/dist/core/tools/omp-seam-runtime.js.map +1 -0
  70. package/dist/core/tools/read.d.ts.map +1 -1
  71. package/dist/core/tools/read.js +34 -1
  72. package/dist/core/tools/read.js.map +1 -1
  73. package/dist/main.d.ts.map +1 -1
  74. package/dist/main.js +112 -11
  75. package/dist/main.js.map +1 -1
  76. package/dist/modes/interactive/components/footer.d.ts +1 -1
  77. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  78. package/dist/modes/interactive/components/footer.js +11 -8
  79. package/dist/modes/interactive/components/footer.js.map +1 -1
  80. package/dist/modes/interactive/theme/omk-aurora-dark.json +80 -0
  81. package/dist/modes/interactive/theme/omk-aurora-light.json +80 -0
  82. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  83. package/dist/modes/interactive/theme/theme.js +5 -0
  84. package/dist/modes/interactive/theme/theme.js.map +1 -1
  85. package/dist/modes/print-mode.d.ts.map +1 -1
  86. package/dist/modes/print-mode.js +9 -0
  87. package/dist/modes/print-mode.js.map +1 -1
  88. package/docs/adaptorch-preview-spec.md +174 -0
  89. package/docs/adaptorch-preview.md +2 -2
  90. package/docs/compaction.md +13 -13
  91. package/docs/correctness-wall.md +13 -5
  92. package/docs/custom-provider.md +8 -8
  93. package/docs/development.md +2 -2
  94. package/docs/extensions.md +8 -8
  95. package/docs/json.md +4 -4
  96. package/docs/provider-resilience.md +87 -0
  97. package/docs/providers.md +1 -1
  98. package/docs/session-format.md +5 -5
  99. package/docs/settings.md +30 -0
  100. package/docs/skills.md +2 -0
  101. package/docs/themes.md +4 -2
  102. package/docs/tui.md +4 -4
  103. package/docs/usage.md +4 -0
  104. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  105. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  106. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  107. package/examples/extensions/gondolin/package-lock.json +2 -2
  108. package/examples/extensions/gondolin/package.json +1 -1
  109. package/examples/extensions/sandbox/package-lock.json +2 -2
  110. package/examples/extensions/sandbox/package.json +1 -1
  111. package/examples/extensions/subagent/README.md +35 -10
  112. package/examples/extensions/subagent/adaptive-agent-runtime.test.ts +234 -0
  113. package/examples/extensions/subagent/adaptive-agent-runtime.ts +240 -0
  114. package/examples/extensions/subagent/adaptive-result.ts +112 -0
  115. package/examples/extensions/subagent/checkpoint-runtime.test.ts +125 -0
  116. package/examples/extensions/subagent/checkpoint-runtime.ts +198 -0
  117. package/examples/extensions/subagent/deadline-budget.test.ts +135 -0
  118. package/examples/extensions/subagent/deadline-budget.ts +242 -0
  119. package/examples/extensions/subagent/deadline-profile-store.test.ts +72 -0
  120. package/examples/extensions/subagent/deadline-profile-store.ts +172 -0
  121. package/examples/extensions/subagent/fixtures/fake-omk-json.mjs +13 -0
  122. package/examples/extensions/subagent/fixtures/hanging-process-tree.mjs +40 -0
  123. package/examples/extensions/subagent/index.ts +253 -137
  124. package/examples/extensions/subagent/managed-process.test.ts +178 -0
  125. package/examples/extensions/subagent/managed-process.ts +236 -0
  126. package/examples/extensions/subagent/subagent-extension-smoke.test.ts +112 -0
  127. package/examples/extensions/subagent/subagent-runtime-types.ts +76 -0
  128. package/examples/extensions/with-deps/package-lock.json +2 -2
  129. package/examples/extensions/with-deps/package.json +1 -1
  130. package/npm-shrinkwrap.json +28 -19
  131. package/package.json +6 -5
@@ -0,0 +1,135 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ allocateAttemptBudget,
4
+ createExecutionBudget,
5
+ estimateTaskDemand,
6
+ getDeadlineProfile,
7
+ planTaskShards,
8
+ recommendAttemptCutoff,
9
+ remainingExecutionMs,
10
+ updateDeadlineProfiles,
11
+ } from "./deadline-budget.ts";
12
+
13
+ describe("subagent dual budget planning", () => {
14
+ it("shards an explicit action list when token and wall-clock demand exceed one attempt", () => {
15
+ const task = [
16
+ "Upgrade the runtime:",
17
+ "1. Inspect the provider boundary.",
18
+ "2. Add typed deadline metadata.",
19
+ "3. Implement checkpoint persistence.",
20
+ "4. Add bounded resume prompts.",
21
+ "5. Run fixture tests.",
22
+ "6. Review process cleanup.",
23
+ ].join("\n");
24
+ const demand = estimateTaskDemand(task);
25
+ const shards = planTaskShards(task, demand, {
26
+ attemptCutoffMs: 180_000,
27
+ maxTaskShards: 3,
28
+ maxTokensPerShard: 40,
29
+ });
30
+
31
+ expect(demand.workUnits).toBeGreaterThanOrEqual(6);
32
+ expect(shards).toHaveLength(3);
33
+ expect(shards.flatMap((shard) => shard.units)).toEqual([
34
+ "Inspect the provider boundary.",
35
+ "Add typed deadline metadata.",
36
+ "Implement checkpoint persistence.",
37
+ "Add bounded resume prompts.",
38
+ "Run fixture tests.",
39
+ "Review process cleanup.",
40
+ ]);
41
+ expect(shards.every((shard) => shard.task.includes("Do only this execution shard"))).toBe(true);
42
+ });
43
+
44
+ it("recognizes explicit CJK numbered actions without relying on English verbs", () => {
45
+ const task = [
46
+ "런타임 업그레이드:",
47
+ "1. 프로세스 경계를 분석한다.",
48
+ "2. 체크포인트 재개를 구현한다.",
49
+ "3. fixture 테스트를 실행한다.",
50
+ ].join("\n");
51
+ const demand = estimateTaskDemand(task);
52
+ const shards = planTaskShards(task, demand, {
53
+ attemptCutoffMs: 100_000,
54
+ maxTaskShards: 3,
55
+ maxTokensPerShard: 20,
56
+ });
57
+
58
+ expect(demand.actionUnits).toEqual([
59
+ "프로세스 경계를 분석한다.",
60
+ "체크포인트 재개를 구현한다.",
61
+ "fixture 테스트를 실행한다.",
62
+ ]);
63
+ expect(shards.length).toBeGreaterThan(1);
64
+ });
65
+
66
+ it("keeps an indivisible task as one shard even when it is expensive", () => {
67
+ const task = "Investigate and fix the runtime without repeating completed side effects.";
68
+ const demand = estimateTaskDemand(task);
69
+ const shards = planTaskShards(task, demand, {
70
+ attemptCutoffMs: 1,
71
+ maxTaskShards: 3,
72
+ maxTokensPerShard: 1,
73
+ });
74
+
75
+ expect(shards).toEqual([{ id: "shard-1", index: 0, task, units: [task] }]);
76
+ });
77
+
78
+ it("reserves cleanup time and divides a sequential deadline among pending work", () => {
79
+ const budget = createExecutionBudget({
80
+ nowMs: 1_000,
81
+ hardDeadlineMs: 900,
82
+ cleanupReserveMs: 100,
83
+ maxResumeAttempts: 1,
84
+ maxTaskShards: 3,
85
+ });
86
+
87
+ expect(remainingExecutionMs(budget, 1_250)).toBe(650);
88
+ expect(
89
+ allocateAttemptBudget({
90
+ budget,
91
+ nowMs: 1_250,
92
+ pendingSequentialTasks: 2,
93
+ pendingShards: 1,
94
+ recommendedCutoffMs: 1_000,
95
+ minimumAttemptMs: 1,
96
+ }),
97
+ ).toEqual({ cutoffMs: 275, taskDeadlineAtMs: 1_525, usableMs: 550 });
98
+ });
99
+ });
100
+
101
+ describe("provider/model deadline learning", () => {
102
+ it("learns completion and cutoff history independently per provider and model", () => {
103
+ let db = updateDeadlineProfiles(undefined, {
104
+ provider: "openai-codex",
105
+ model: "gpt-5.6",
106
+ outcome: "completed",
107
+ elapsedMs: 120_000,
108
+ estimatedTokens: 2_000,
109
+ workUnits: 2,
110
+ });
111
+ db = updateDeadlineProfiles(db, {
112
+ provider: "openai-codex",
113
+ model: "gpt-5.6",
114
+ outcome: "cutoff",
115
+ elapsedMs: 600_000,
116
+ estimatedTokens: 2_000,
117
+ workUnits: 2,
118
+ });
119
+ db = updateDeadlineProfiles(db, {
120
+ provider: "anthropic",
121
+ model: "claude-sonnet",
122
+ outcome: "completed",
123
+ elapsedMs: 40_000,
124
+ estimatedTokens: 500,
125
+ workUnits: 1,
126
+ });
127
+
128
+ const codex = getDeadlineProfile(db, "openai-codex", "gpt-5.6");
129
+ const anthropic = getDeadlineProfile(db, "anthropic", "claude-sonnet");
130
+ expect(codex).toMatchObject({ samples: 2, completions: 1, cutoffs: 1 });
131
+ expect(anthropic).toMatchObject({ samples: 1, completions: 1, cutoffs: 0 });
132
+ expect(recommendAttemptCutoff(codex, { fallbackMs: 600_000, minimumMs: 1, maximumMs: 800_000 })).toBe(180_000);
133
+ expect(recommendAttemptCutoff(anthropic, { fallbackMs: 600_000, minimumMs: 1, maximumMs: 800_000 })).toBe(60_000);
134
+ });
135
+ });
@@ -0,0 +1,242 @@
1
+ export type DeadlineSampleOutcome = "completed" | "cutoff" | "aborted" | "failed";
2
+
3
+ export interface TaskDemand {
4
+ readonly estimatedTokens: number;
5
+ readonly workUnits: number;
6
+ readonly predictedMs: number;
7
+ readonly actionUnits: readonly string[];
8
+ }
9
+
10
+ export interface TaskShard {
11
+ readonly id: string;
12
+ readonly index: number;
13
+ readonly task: string;
14
+ readonly units: readonly string[];
15
+ }
16
+
17
+ export interface ExecutionBudget {
18
+ readonly startedAtMs: number;
19
+ readonly hardDeadlineMs: number;
20
+ readonly deadlineAtMs: number;
21
+ readonly cleanupReserveMs: number;
22
+ readonly maxResumeAttempts: number;
23
+ readonly maxTaskShards: number;
24
+ }
25
+
26
+ export interface DeadlineProfileSample {
27
+ readonly provider: string;
28
+ readonly model: string;
29
+ readonly outcome: DeadlineSampleOutcome;
30
+ readonly elapsedMs: number;
31
+ readonly estimatedTokens: number;
32
+ readonly workUnits: number;
33
+ }
34
+
35
+ export interface DeadlineProfile {
36
+ readonly provider: string;
37
+ readonly model: string;
38
+ readonly samples: number;
39
+ readonly completions: number;
40
+ readonly cutoffs: number;
41
+ readonly aborts: number;
42
+ readonly failures: number;
43
+ readonly ewmaElapsedMs: number;
44
+ readonly ewmaMsPerWorkUnit: number;
45
+ readonly completedDurationsMs: readonly number[];
46
+ readonly cutoffDurationsMs: readonly number[];
47
+ }
48
+
49
+ export interface DeadlineProfileDb {
50
+ readonly version: 1;
51
+ readonly profiles: Readonly<Record<string, DeadlineProfile>>;
52
+ }
53
+
54
+ interface CreateExecutionBudgetOptions {
55
+ readonly nowMs?: number;
56
+ readonly hardDeadlineMs: number;
57
+ readonly cleanupReserveMs: number;
58
+ readonly maxResumeAttempts: number;
59
+ readonly maxTaskShards: number;
60
+ }
61
+
62
+ interface PlanTaskShardsOptions {
63
+ readonly attemptCutoffMs: number;
64
+ readonly maxTaskShards: number;
65
+ readonly maxTokensPerShard: number;
66
+ }
67
+
68
+ interface AllocateAttemptBudgetOptions {
69
+ readonly budget: ExecutionBudget;
70
+ readonly nowMs?: number;
71
+ readonly pendingSequentialTasks: number;
72
+ readonly pendingShards: number;
73
+ readonly recommendedCutoffMs: number;
74
+ readonly minimumAttemptMs: number;
75
+ }
76
+
77
+ interface RecommendCutoffOptions {
78
+ readonly fallbackMs: number;
79
+ readonly minimumMs: number;
80
+ readonly maximumMs: number;
81
+ }
82
+
83
+ const EXPLICIT_ACTION_LINE = /^\s*(?:\d+[.)]|\[[ xX]\])\s+(.+?)\s*$/;
84
+ const BULLET_ACTION_LINE = /^\s*[-*+]\s+(.+?)\s*$/;
85
+ const ACTION_VERB =
86
+ /^(?:inspect|add|implement|create|fix|test|run|review|analy[sz]e|verify|update|refactor|migrate|remove|investigate|write|read|document|build|check)\b/i;
87
+ const MAX_PROFILE_SAMPLES = 24;
88
+ const PROFILE_ALPHA = 0.25;
89
+
90
+ export function estimateTokens(text: string): number {
91
+ return Math.max(1, Math.ceil(Buffer.byteLength(text, "utf8") / 4));
92
+ }
93
+
94
+ export function estimateTaskDemand(task: string): TaskDemand {
95
+ const actionUnits = task
96
+ .split(/\r?\n/)
97
+ .map(extractActionUnit)
98
+ .filter((line): line is string => line !== undefined);
99
+ const estimatedTokens = estimateTokens(task);
100
+ const pathSignals = new Set(task.match(/(?:\.?\.?\/)?[\w.-]+(?:\/[\w.-]+)+/g) ?? []).size;
101
+ const verificationSignals = (task.match(/\b(?:test|typecheck|build|lint|review|verify)\b/gi) ?? []).length;
102
+ const workUnits = Math.max(1, actionUnits.length, Math.min(12, pathSignals + verificationSignals));
103
+ const predictedMs = 45_000 + workUnits * 75_000 + Math.min(120_000, estimatedTokens * 20);
104
+ return { estimatedTokens, workUnits, predictedMs, actionUnits };
105
+ }
106
+
107
+ export function planTaskShards(task: string, demand: TaskDemand, options: PlanTaskShardsOptions): TaskShard[] {
108
+ if (demand.actionUnits.length < 2) return [{ id: "shard-1", index: 0, task, units: [task] }];
109
+ const tokenShards = Math.ceil(demand.estimatedTokens / Math.max(1, options.maxTokensPerShard));
110
+ const timeShards = Math.ceil(demand.predictedMs / Math.max(1, options.attemptCutoffMs));
111
+ const shardCount = Math.min(options.maxTaskShards, demand.actionUnits.length, Math.max(1, tokenShards, timeShards));
112
+ if (shardCount === 1) return [{ id: "shard-1", index: 0, task, units: demand.actionUnits }];
113
+
114
+ const shards: TaskShard[] = [];
115
+ for (let index = 0; index < shardCount; index++) {
116
+ const start = Math.floor((index * demand.actionUnits.length) / shardCount);
117
+ const end = Math.floor(((index + 1) * demand.actionUnits.length) / shardCount);
118
+ const units = demand.actionUnits.slice(start, end);
119
+ const current = units.map((unit, unitIndex) => `${unitIndex + 1}. ${unit}`).join("\n");
120
+ shards.push({
121
+ id: `shard-${index + 1}`,
122
+ index,
123
+ units,
124
+ task: [
125
+ "Original task context:",
126
+ task,
127
+ "",
128
+ `Do only this execution shard (${index + 1}/${shardCount}):`,
129
+ current,
130
+ "Do not execute later shards; leave them for the runtime scheduler.",
131
+ ].join("\n"),
132
+ });
133
+ }
134
+ return shards;
135
+ }
136
+
137
+ export function createExecutionBudget(options: CreateExecutionBudgetOptions): ExecutionBudget {
138
+ const startedAtMs = options.nowMs ?? Date.now();
139
+ const hardDeadlineMs = Math.max(1, Math.floor(options.hardDeadlineMs));
140
+ return {
141
+ startedAtMs,
142
+ hardDeadlineMs,
143
+ deadlineAtMs: startedAtMs + hardDeadlineMs,
144
+ cleanupReserveMs: Math.max(0, Math.floor(options.cleanupReserveMs)),
145
+ maxResumeAttempts: Math.max(0, Math.floor(options.maxResumeAttempts)),
146
+ maxTaskShards: Math.max(1, Math.floor(options.maxTaskShards)),
147
+ };
148
+ }
149
+
150
+ export function remainingExecutionMs(budget: ExecutionBudget, nowMs = Date.now()): number {
151
+ return Math.max(0, budget.deadlineAtMs - nowMs);
152
+ }
153
+
154
+ export function allocateAttemptBudget(options: AllocateAttemptBudgetOptions): {
155
+ readonly cutoffMs: number;
156
+ readonly taskDeadlineAtMs: number;
157
+ readonly usableMs: number;
158
+ } {
159
+ const nowMs = options.nowMs ?? Date.now();
160
+ const usableMs = Math.max(0, remainingExecutionMs(options.budget, nowMs) - options.budget.cleanupReserveMs);
161
+ const taskShareMs = Math.floor(usableMs / Math.max(1, options.pendingSequentialTasks));
162
+ const shardShareMs = Math.floor(taskShareMs / Math.max(1, options.pendingShards));
163
+ const cutoffMs = Math.max(0, Math.min(options.recommendedCutoffMs, shardShareMs));
164
+ return {
165
+ cutoffMs: cutoffMs >= options.minimumAttemptMs ? cutoffMs : 0,
166
+ taskDeadlineAtMs: nowMs + taskShareMs,
167
+ usableMs,
168
+ };
169
+ }
170
+
171
+ export function profileKey(provider: string, model: string): string {
172
+ return `${provider.trim() || "unknown"}/${model.trim() || "default"}`;
173
+ }
174
+
175
+ export function getDeadlineProfile(
176
+ db: DeadlineProfileDb | undefined,
177
+ provider: string,
178
+ model: string,
179
+ ): DeadlineProfile | undefined {
180
+ return db?.profiles[profileKey(provider, model)];
181
+ }
182
+
183
+ export function updateDeadlineProfiles(
184
+ db: DeadlineProfileDb | undefined,
185
+ sample: DeadlineProfileSample,
186
+ ): DeadlineProfileDb {
187
+ const key = profileKey(sample.provider, sample.model);
188
+ const previous = db?.profiles[key];
189
+ const elapsedMs = Math.max(1, Math.floor(sample.elapsedMs));
190
+ const workUnits = Math.max(1, sample.workUnits);
191
+ const next: DeadlineProfile = {
192
+ provider: sample.provider,
193
+ model: sample.model,
194
+ samples: (previous?.samples ?? 0) + 1,
195
+ completions: (previous?.completions ?? 0) + Number(sample.outcome === "completed"),
196
+ cutoffs: (previous?.cutoffs ?? 0) + Number(sample.outcome === "cutoff"),
197
+ aborts: (previous?.aborts ?? 0) + Number(sample.outcome === "aborted"),
198
+ failures: (previous?.failures ?? 0) + Number(sample.outcome === "failed"),
199
+ ewmaElapsedMs: ewma(previous?.ewmaElapsedMs, elapsedMs),
200
+ ewmaMsPerWorkUnit: ewma(previous?.ewmaMsPerWorkUnit, elapsedMs / workUnits),
201
+ completedDurationsMs: appendBounded(
202
+ previous?.completedDurationsMs,
203
+ sample.outcome === "completed" ? elapsedMs : undefined,
204
+ ),
205
+ cutoffDurationsMs: appendBounded(
206
+ previous?.cutoffDurationsMs,
207
+ sample.outcome === "cutoff" ? elapsedMs : undefined,
208
+ ),
209
+ };
210
+ return { version: 1, profiles: { ...(db?.profiles ?? {}), [key]: next } };
211
+ }
212
+
213
+ export function recommendAttemptCutoff(profile: DeadlineProfile | undefined, options: RecommendCutoffOptions): number {
214
+ const completed = percentile(profile?.completedDurationsMs ?? [], 0.9);
215
+ const cutoff = percentile(profile?.cutoffDurationsMs ?? [], 0.25);
216
+ const candidates = [options.fallbackMs];
217
+ if (completed !== undefined) candidates.push(Math.round(completed * 1.5));
218
+ if (cutoff !== undefined) candidates.push(Math.round(cutoff * 0.75));
219
+ return Math.max(options.minimumMs, Math.min(options.maximumMs, ...candidates));
220
+ }
221
+
222
+ function extractActionUnit(line: string): string | undefined {
223
+ const explicit = line.match(EXPLICIT_ACTION_LINE)?.[1]?.trim();
224
+ if (explicit) return explicit;
225
+ const bullet = line.match(BULLET_ACTION_LINE)?.[1]?.trim();
226
+ return bullet && ACTION_VERB.test(bullet) ? bullet : undefined;
227
+ }
228
+
229
+ function ewma(previous: number | undefined, value: number): number {
230
+ return Math.round(previous === undefined ? value : previous * (1 - PROFILE_ALPHA) + value * PROFILE_ALPHA);
231
+ }
232
+
233
+ function appendBounded(previous: readonly number[] | undefined, value: number | undefined): number[] {
234
+ if (value === undefined) return [...(previous ?? [])];
235
+ return [...(previous ?? []), value].slice(-MAX_PROFILE_SAMPLES);
236
+ }
237
+
238
+ function percentile(values: readonly number[], ratio: number): number | undefined {
239
+ if (values.length === 0) return undefined;
240
+ const sorted = [...values].sort((left, right) => left - right);
241
+ return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * ratio))];
242
+ }
@@ -0,0 +1,72 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { DeadlineProfileStore } from "./deadline-profile-store.ts";
6
+
7
+ const cleanupPaths: string[] = [];
8
+
9
+ afterEach(async () => {
10
+ await Promise.all(cleanupPaths.splice(0).map((entry) => fs.promises.rm(entry, { force: true, recursive: true })));
11
+ });
12
+
13
+ describe("DeadlineProfileStore", () => {
14
+ it("persists provider/model cutoff history across dispatcher processes", async () => {
15
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "omk-deadline-profile-"));
16
+ cleanupPaths.push(dir);
17
+ const filePath = path.join(dir, "profiles.json");
18
+ const first = new DeadlineProfileStore(filePath);
19
+ await first.record({
20
+ provider: "openai-codex",
21
+ model: "gpt-5.6",
22
+ outcome: "cutoff",
23
+ elapsedMs: 500,
24
+ estimatedTokens: 100,
25
+ workUnits: 2,
26
+ });
27
+
28
+ const reloaded = new DeadlineProfileStore(filePath);
29
+ expect(await reloaded.profileFor("openai-codex", "gpt-5.6")).toMatchObject({ samples: 1, cutoffs: 1 });
30
+ });
31
+
32
+ it("serializes concurrent samples without losing updates", async () => {
33
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "omk-deadline-concurrent-"));
34
+ cleanupPaths.push(dir);
35
+ const filePath = path.join(dir, "profiles.json");
36
+ const stores = [new DeadlineProfileStore(filePath), new DeadlineProfileStore(filePath)];
37
+ await Promise.all(
38
+ Array.from({ length: 8 }, (_, index) =>
39
+ stores[index % stores.length].record({
40
+ provider: "anthropic",
41
+ model: "claude-sonnet",
42
+ outcome: "completed",
43
+ elapsedMs: 100 + index,
44
+ estimatedTokens: 10,
45
+ workUnits: 1,
46
+ }),
47
+ ),
48
+ );
49
+
50
+ const reloaded = new DeadlineProfileStore(filePath);
51
+ expect(await reloaded.profileFor("anthropic", "claude-sonnet")).toMatchObject({ samples: 8, completions: 8 });
52
+ });
53
+
54
+ it("recovers from malformed history without exposing untyped data", async () => {
55
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "omk-deadline-corrupt-"));
56
+ cleanupPaths.push(dir);
57
+ const filePath = path.join(dir, "profiles.json");
58
+ await fs.promises.writeFile(filePath, "{not-json", "utf8");
59
+ const store = new DeadlineProfileStore(filePath);
60
+
61
+ expect(await store.profileFor("unknown", "default")).toBeUndefined();
62
+ await store.record({
63
+ provider: "unknown",
64
+ model: "default",
65
+ outcome: "failed",
66
+ elapsedMs: 20,
67
+ estimatedTokens: 5,
68
+ workUnits: 1,
69
+ });
70
+ expect(JSON.parse(await fs.promises.readFile(filePath, "utf8"))).toMatchObject({ version: 1 });
71
+ });
72
+ });
@@ -0,0 +1,172 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import {
4
+ type DeadlineProfile,
5
+ type DeadlineProfileDb,
6
+ type DeadlineProfileSample,
7
+ getDeadlineProfile,
8
+ updateDeadlineProfiles,
9
+ } from "./deadline-budget.ts";
10
+
11
+ const MAX_PROFILE_FILE_BYTES = 1024 * 1024;
12
+ const MAX_PROFILE_ENTRIES = 1_000;
13
+ const LOCK_RETRY_COUNT = 200;
14
+ const LOCK_RETRY_MS = 10;
15
+ const STALE_LOCK_MS = 30_000;
16
+
17
+ export class DeadlineProfileLockError extends Error {
18
+ constructor(filePath: string) {
19
+ super(`Timed out acquiring deadline profile lock for ${filePath}`);
20
+ this.name = "DeadlineProfileLockError";
21
+ }
22
+ }
23
+
24
+ export class DeadlineProfileStore {
25
+ private readonly filePath: string;
26
+ private db: DeadlineProfileDb = emptyDb();
27
+ private mutationTail: Promise<void> = Promise.resolve();
28
+
29
+ constructor(filePath: string) {
30
+ this.filePath = filePath;
31
+ }
32
+
33
+ async profileFor(provider: string, model: string): Promise<DeadlineProfile | undefined> {
34
+ await this.mutationTail;
35
+ this.db = await readDb(this.filePath);
36
+ return getDeadlineProfile(this.db, provider, model);
37
+ }
38
+
39
+ async record(sample: DeadlineProfileSample): Promise<DeadlineProfile> {
40
+ let recorded: DeadlineProfile | undefined;
41
+ const operation = this.mutationTail.then(async () => {
42
+ await withProfileLock(this.filePath, async () => {
43
+ this.db = updateDeadlineProfiles(await readDb(this.filePath), sample);
44
+ recorded = getDeadlineProfile(this.db, sample.provider, sample.model);
45
+ if (recorded === undefined) throw new Error("Deadline profile update did not produce a profile");
46
+ await writeAtomically(this.filePath, JSON.stringify(this.db));
47
+ });
48
+ });
49
+ this.mutationTail = operation.catch(() => undefined);
50
+ await operation;
51
+ if (recorded === undefined) throw new Error("Deadline profile update did not complete");
52
+ return recorded;
53
+ }
54
+ }
55
+
56
+ async function withProfileLock<T>(filePath: string, operation: () => Promise<T>): Promise<T> {
57
+ const lockPath = `${filePath}.lock`;
58
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
59
+ for (let attempt = 0; attempt < LOCK_RETRY_COUNT; attempt++) {
60
+ try {
61
+ await fs.promises.mkdir(lockPath);
62
+ try {
63
+ return await operation();
64
+ } finally {
65
+ await fs.promises.rm(lockPath, { recursive: true, force: true });
66
+ }
67
+ } catch (error) {
68
+ if (!isAlreadyExists(error)) throw error;
69
+ await removeStaleLock(lockPath);
70
+ await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS));
71
+ }
72
+ }
73
+ throw new DeadlineProfileLockError(filePath);
74
+ }
75
+
76
+ async function removeStaleLock(lockPath: string): Promise<void> {
77
+ try {
78
+ const stats = await fs.promises.stat(lockPath);
79
+ if (Date.now() - stats.mtimeMs > STALE_LOCK_MS) {
80
+ await fs.promises.rm(lockPath, { recursive: true, force: true });
81
+ }
82
+ } catch {
83
+ return;
84
+ }
85
+ }
86
+
87
+ function isAlreadyExists(error: unknown): boolean {
88
+ return error instanceof Error && "code" in error && error.code === "EEXIST";
89
+ }
90
+
91
+ async function readDb(filePath: string): Promise<DeadlineProfileDb> {
92
+ try {
93
+ const handle = await fs.promises.open(filePath, "r");
94
+ try {
95
+ const stats = await handle.stat();
96
+ if (stats.size > MAX_PROFILE_FILE_BYTES) return emptyDb();
97
+ return parseDb(JSON.parse(await handle.readFile("utf8")));
98
+ } finally {
99
+ await handle.close();
100
+ }
101
+ } catch {
102
+ return emptyDb();
103
+ }
104
+ }
105
+
106
+ async function writeAtomically(filePath: string, content: string): Promise<void> {
107
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
108
+ const tempPath = `${filePath}.${process.pid}.${process.hrtime.bigint()}.tmp`;
109
+ try {
110
+ await fs.promises.writeFile(tempPath, content, { encoding: "utf8", mode: 0o600 });
111
+ await fs.promises.rename(tempPath, filePath);
112
+ } finally {
113
+ await fs.promises.rm(tempPath, { force: true });
114
+ }
115
+ }
116
+
117
+ function parseDb(value: unknown): DeadlineProfileDb {
118
+ if (!isRecord(value) || value.version !== 1 || !isRecord(value.profiles)) return emptyDb();
119
+ const profiles: Record<string, DeadlineProfile> = Object.create(null);
120
+ for (const [key, raw] of Object.entries(value.profiles).slice(0, MAX_PROFILE_ENTRIES)) {
121
+ const parsed = parseProfile(raw);
122
+ if (parsed !== undefined) profiles[key] = parsed;
123
+ }
124
+ return { version: 1, profiles };
125
+ }
126
+
127
+ function parseProfile(value: unknown): DeadlineProfile | undefined {
128
+ if (!isRecord(value) || typeof value.provider !== "string" || typeof value.model !== "string") return undefined;
129
+ const samples = readCount(value.samples);
130
+ const completions = readCount(value.completions);
131
+ const cutoffs = readCount(value.cutoffs);
132
+ const aborts = readCount(value.aborts);
133
+ const failures = readCount(value.failures);
134
+ const ewmaElapsedMs = readCount(value.ewmaElapsedMs);
135
+ const ewmaMsPerWorkUnit = readCount(value.ewmaMsPerWorkUnit);
136
+ if ([samples, completions, cutoffs, aborts, failures, ewmaElapsedMs, ewmaMsPerWorkUnit].includes(undefined)) {
137
+ return undefined;
138
+ }
139
+ return {
140
+ provider: value.provider.slice(0, 256),
141
+ model: value.model.slice(0, 256),
142
+ samples: samples ?? 0,
143
+ completions: completions ?? 0,
144
+ cutoffs: cutoffs ?? 0,
145
+ aborts: aborts ?? 0,
146
+ failures: failures ?? 0,
147
+ ewmaElapsedMs: ewmaElapsedMs ?? 0,
148
+ ewmaMsPerWorkUnit: ewmaMsPerWorkUnit ?? 0,
149
+ completedDurationsMs: readDurations(value.completedDurationsMs),
150
+ cutoffDurationsMs: readDurations(value.cutoffDurationsMs),
151
+ };
152
+ }
153
+
154
+ function readCount(value: unknown): number | undefined {
155
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : undefined;
156
+ }
157
+
158
+ function readDurations(value: unknown): number[] {
159
+ if (!Array.isArray(value)) return [];
160
+ return value
161
+ .map(readCount)
162
+ .filter((duration): duration is number => duration !== undefined && duration > 0)
163
+ .slice(-24);
164
+ }
165
+
166
+ function emptyDb(): DeadlineProfileDb {
167
+ return { version: 1, profiles: {} };
168
+ }
169
+
170
+ function isRecord(value: unknown): value is Record<string, unknown> {
171
+ return typeof value === "object" && value !== null && !Array.isArray(value);
172
+ }
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+
3
+ process.stdout.write(
4
+ `${JSON.stringify({
5
+ type: "message_end",
6
+ message: {
7
+ role: "assistant",
8
+ content: [{ type: "text", text: "fixture subagent completed" }],
9
+ provider: "fixture-provider",
10
+ model: "fixture-model",
11
+ },
12
+ })}\n`,
13
+ );
@@ -0,0 +1,40 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ const normalOrphan = process.argv.includes("--normal-orphan");
4
+
5
+ if (!process.argv.includes("--parent-exits") && !normalOrphan) {
6
+ process.on("SIGTERM", () => {
7
+ // Exercise the managed runner's SIGKILL escalation path.
8
+ });
9
+ }
10
+
11
+ // The grandchild ignores SIGTERM so the managed runner must escalate to
12
+ // SIGKILL. It writes "ready" on its stdout (piped back to this parent) only
13
+ // AFTER installing its SIGTERM handler. The parent waits for that signal
14
+ // before exiting or settling into its keep-alive loop, so the runner never
15
+ // signals the process group while the grandchild is still in its startup
16
+ // window (a fresh `node -e` process needs ~50-100ms before its first JS line
17
+ // runs). Without this handshake a SIGTERM can land before the handler is
18
+ // installed, killing the grandchild via the default handler and making the
19
+ // SIGKILL escalation (cleanup.killSent) a timing race.
20
+ const grandchild = spawn(
21
+ process.execPath,
22
+ ["-e", "process.on('SIGTERM', () => {}); process.stdout.write('ready'); setInterval(() => {}, 1000)"],
23
+ {
24
+ stdio: ["ignore", "pipe", "ignore"],
25
+ },
26
+ );
27
+
28
+ process.stdout.write(`${JSON.stringify({ grandchildPid: grandchild.pid })}\n`);
29
+
30
+ const startMain = () => {
31
+ if (normalOrphan) setTimeout(() => process.exit(0), 20);
32
+ else setInterval(() => {}, 1000);
33
+ };
34
+
35
+ let started = false;
36
+ grandchild.stdout.on("data", () => {
37
+ if (started) return;
38
+ started = true;
39
+ startMain();
40
+ });