@twelvehart/orcats 0.2.3 → 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 (35) hide show
  1. package/README.md +3 -3
  2. package/dist/backends/claude-run.d.ts.map +1 -1
  3. package/dist/backends/codex-jsonl.d.ts +2 -1
  4. package/dist/backends/codex-jsonl.d.ts.map +1 -1
  5. package/dist/backends/codex-run.d.ts +2 -1
  6. package/dist/backends/codex-run.d.ts.map +1 -1
  7. package/dist/backends/codex.d.ts.map +1 -1
  8. package/dist/backends/pi-run.d.ts.map +1 -1
  9. package/dist/backends/select.d.ts +1 -1
  10. package/dist/backends/select.d.ts.map +1 -1
  11. package/dist/backends/subprocess-run.d.ts +4 -5
  12. package/dist/backends/subprocess-run.d.ts.map +1 -1
  13. package/dist/backends/subprocess-termination.d.ts +4 -0
  14. package/dist/backends/subprocess-termination.d.ts.map +1 -0
  15. package/dist/cli/version.d.ts +1 -1
  16. package/dist/conversation/conversation.d.ts +2 -0
  17. package/dist/conversation/conversation.d.ts.map +1 -1
  18. package/dist/conversation/settlement-reservation.d.ts +10 -0
  19. package/dist/conversation/settlement-reservation.d.ts.map +1 -0
  20. package/dist/model/backend-config.d.ts +2 -0
  21. package/dist/model/backend-config.d.ts.map +1 -1
  22. package/package.json +2 -2
  23. package/src/backends/claude-run.ts +7 -2
  24. package/src/backends/codex-jsonl.ts +5 -0
  25. package/src/backends/codex-run.ts +15 -1
  26. package/src/backends/codex.ts +4 -1
  27. package/src/backends/pi-run.ts +8 -2
  28. package/src/backends/select.ts +4 -1
  29. package/src/backends/subprocess-run.ts +534 -58
  30. package/src/backends/subprocess-termination.ts +56 -0
  31. package/src/cli/main.ts +2 -1
  32. package/src/cli/version.ts +1 -1
  33. package/src/conversation/conversation.ts +92 -7
  34. package/src/conversation/settlement-reservation.ts +127 -0
  35. package/src/model/backend-config.ts +8 -0
@@ -0,0 +1,56 @@
1
+ import type { SubprocessProcess } from "./subprocess-run.ts";
2
+
3
+ const DefaultSubprocessTerminationGraceMs = 1_000;
4
+ const DefaultSubprocessForceGraceMs = 1_000;
5
+
6
+ const exitWaitCancellations = new WeakMap<
7
+ SubprocessProcess,
8
+ (error: unknown) => void
9
+ >();
10
+
11
+ export function registerSubprocessExitWaitCancellation(
12
+ process: SubprocessProcess,
13
+ cancel: (error: unknown) => void
14
+ ): void {
15
+ exitWaitCancellations.set(process, cancel);
16
+ }
17
+
18
+ export async function terminateSubprocess(
19
+ process: SubprocessProcess,
20
+ gracefulMs = DefaultSubprocessTerminationGraceMs,
21
+ forceMs = DefaultSubprocessForceGraceMs
22
+ ): Promise<void> {
23
+ try {
24
+ process.kill("SIGTERM");
25
+ if (await exitsWithin(process.exit, gracefulMs)) {
26
+ return;
27
+ }
28
+
29
+ process.kill("SIGKILL");
30
+ if (!(await exitsWithin(process.exit, forceMs))) {
31
+ throw new Error("subprocess did not exit after SIGKILL");
32
+ }
33
+ } catch (error) {
34
+ const cancelExitWait = exitWaitCancellations.get(process);
35
+ exitWaitCancellations.delete(process);
36
+ cancelExitWait?.(error);
37
+ throw error;
38
+ }
39
+ }
40
+
41
+ async function exitsWithin(
42
+ exit: Promise<number | null>,
43
+ timeoutMs: number
44
+ ): Promise<boolean> {
45
+ let timer: ReturnType<typeof setTimeout> | undefined;
46
+ const timedOut = new Promise<false>((resolve) => {
47
+ timer = setTimeout(() => {
48
+ resolve(false);
49
+ }, Math.max(timeoutMs, 0));
50
+ });
51
+ try {
52
+ return await Promise.race([exit.then(() => true as const), timedOut]);
53
+ } finally {
54
+ clearTimeout(timer);
55
+ }
56
+ }
package/src/cli/main.ts CHANGED
@@ -321,7 +321,8 @@ function describeError(error: unknown): string {
321
321
  return error.reason;
322
322
  }
323
323
  try {
324
- return JSON.stringify(error);
324
+ const serialized = JSON.stringify(error) as string | undefined;
325
+ return serialized ?? String(error);
325
326
  } catch {
326
327
  return String(error);
327
328
  }
@@ -1 +1 @@
1
- export const ORCA_VERSION = "0.2.3";
1
+ export const ORCA_VERSION = "0.3.0";
@@ -4,9 +4,15 @@ import {
4
4
  type BackendTag,
5
5
  type ConversationEvent,
6
6
  type RuntimeError,
7
+ backendFailed,
7
8
  unsupportedFeature
8
9
  } from "../model/index.ts";
9
10
  import { BoundedAsyncQueue } from "./queue.ts";
11
+ import {
12
+ deferConversationSettlement,
13
+ markConversationCancellationComplete,
14
+ registerConversationCancellationFailureHandler
15
+ } from "./settlement-reservation.ts";
10
16
 
11
17
  export type Outcome<B extends BackendTag = BackendTag> =
12
18
  | { readonly type: "success"; readonly result: BackendResult<B> }
@@ -39,6 +45,7 @@ export class StreamConversation<B extends BackendTag> implements Conversation<B>
39
45
  private readonly outcome: Promise<Outcome<B>>;
40
46
  private settle!: (outcome: Outcome<B>) => void;
41
47
  private settled = false;
48
+ private cancellation: Promise<void> | undefined;
42
49
 
43
50
  constructor(private readonly options: StreamConversationOptions<B>) {
44
51
  this.queue = new BoundedAsyncQueue(options.capacity ?? 32);
@@ -80,21 +87,80 @@ export class StreamConversation<B extends BackendTag> implements Conversation<B>
80
87
  }
81
88
 
82
89
  succeed(result: BackendResult<B>): void {
83
- this.complete({ type: "success", result });
90
+ if (this.cancellation !== undefined) {
91
+ return;
92
+ }
93
+ const outcome: Outcome<B> = { type: "success", result };
94
+ if (
95
+ deferConversationSettlement(this, "success", () => {
96
+ if (this.cancellation === undefined) {
97
+ this.complete(outcome);
98
+ }
99
+ })
100
+ ) {
101
+ return;
102
+ }
103
+ this.complete(outcome);
84
104
  }
85
105
 
86
106
  fail(error: RuntimeError): void {
87
- this.complete({ type: "failed", error });
107
+ if (this.cancellation !== undefined) {
108
+ return;
109
+ }
110
+ const outcome: Outcome<B> = { type: "failed", error };
111
+ if (
112
+ deferConversationSettlement(this, "failure", () => {
113
+ if (this.cancellation === undefined) {
114
+ this.complete(outcome);
115
+ }
116
+ })
117
+ ) {
118
+ return;
119
+ }
120
+ this.complete(outcome);
88
121
  }
89
122
 
90
- async cancel(reason?: string): Promise<void> {
123
+ cancel(reason?: string): Promise<void> {
91
124
  if (this.settled) {
92
- return;
125
+ return Promise.resolve();
126
+ }
127
+ if (this.cancellation !== undefined) {
128
+ return this.cancellation;
93
129
  }
94
130
 
95
- this.abortController.abort(reason);
96
- await this.options.onCancel?.(reason);
97
- this.complete(reason === undefined ? { type: "cancelled" } : { type: "cancelled", reason });
131
+ const cancellation = Promise.withResolvers<undefined>();
132
+ this.cancellation = cancellation.promise;
133
+ registerConversationCancellationFailureHandler(this, (error: unknown) => {
134
+ this.completeCancellationFailure(error, cancellation.reject);
135
+ });
136
+
137
+ let cleanup: Promise<void>;
138
+ try {
139
+ this.abortController.abort(reason);
140
+ cleanup = Promise.resolve(this.options.onCancel?.(reason));
141
+ } catch (error) {
142
+ this.completeCancellationFailure(error, cancellation.reject);
143
+ return cancellation.promise;
144
+ }
145
+
146
+ void cleanup.then(
147
+ () => {
148
+ const outcome: Outcome<B> =
149
+ reason === undefined ? { type: "cancelled" } : { type: "cancelled", reason };
150
+ const publish = (): void => {
151
+ this.complete(outcome);
152
+ cancellation.resolve(undefined);
153
+ };
154
+ if (!deferConversationSettlement(this, "cancellation", publish)) {
155
+ publish();
156
+ }
157
+ markConversationCancellationComplete(this);
158
+ },
159
+ (error: unknown) => {
160
+ this.completeCancellationFailure(error, cancellation.reject);
161
+ }
162
+ );
163
+ return cancellation.promise;
98
164
  }
99
165
 
100
166
  private complete(outcome: Outcome<B>): void {
@@ -106,4 +172,23 @@ export class StreamConversation<B extends BackendTag> implements Conversation<B>
106
172
  this.queue.close();
107
173
  this.settle(outcome);
108
174
  }
175
+
176
+ private completeCancellationFailure(
177
+ error: unknown,
178
+ rejectCancellation: (reason?: unknown) => void
179
+ ): void {
180
+ const message = error instanceof Error ? error.message : String(error);
181
+ const outcome: Outcome<B> = {
182
+ type: "failed",
183
+ error: backendFailed(this.backend, `${this.backend} cancellation cleanup failed: ${message}`)
184
+ };
185
+ const publish = (): void => {
186
+ this.complete(outcome);
187
+ rejectCancellation(error);
188
+ };
189
+ if (!deferConversationSettlement(this, "cancellation_failure", publish)) {
190
+ publish();
191
+ }
192
+ markConversationCancellationComplete(this);
193
+ }
109
194
  }
@@ -0,0 +1,127 @@
1
+ type SettlementKind = "success" | "cancellation" | "failure" | "cancellation_failure";
2
+
3
+ const settlementPriority: Readonly<Record<SettlementKind, number>> = {
4
+ success: 0,
5
+ failure: 1,
6
+ cancellation: 2,
7
+ cancellation_failure: 3
8
+ };
9
+
10
+ interface PendingSettlement {
11
+ readonly kind: SettlementKind;
12
+ readonly publish: () => void;
13
+ }
14
+
15
+ interface SettlementReservation {
16
+ depth: number;
17
+ pending?: PendingSettlement;
18
+ }
19
+
20
+ const reservations = new WeakMap<object, SettlementReservation>();
21
+
22
+ interface CancellationCompletion {
23
+ readonly promise: Promise<void>;
24
+ readonly resolve: () => void;
25
+ completed: boolean;
26
+ }
27
+
28
+ const cancellationCompletions = new WeakMap<object, CancellationCompletion>();
29
+ const cancellationFailureHandlers = new WeakMap<object, (error: unknown) => void>();
30
+
31
+ function cancellationCompletion(conversation: object): CancellationCompletion {
32
+ const existing = cancellationCompletions.get(conversation);
33
+ if (existing !== undefined) {
34
+ return existing;
35
+ }
36
+
37
+ const deferred = Promise.withResolvers<undefined>();
38
+ const created: CancellationCompletion = {
39
+ promise: deferred.promise,
40
+ resolve: () => {
41
+ deferred.resolve(undefined);
42
+ },
43
+ completed: false
44
+ };
45
+ cancellationCompletions.set(conversation, created);
46
+ return created;
47
+ }
48
+
49
+ export function observeConversationCancellationCompletion(
50
+ conversation: object
51
+ ): Promise<void> {
52
+ return cancellationCompletion(conversation).promise;
53
+ }
54
+
55
+ export function markConversationCancellationComplete(conversation: object): void {
56
+ const completion = cancellationCompletion(conversation);
57
+ if (completion.completed) {
58
+ return;
59
+ }
60
+ completion.completed = true;
61
+ completion.resolve();
62
+ }
63
+
64
+ export function registerConversationCancellationFailureHandler(
65
+ conversation: object,
66
+ handler: (error: unknown) => void
67
+ ): void {
68
+ cancellationFailureHandlers.set(conversation, handler);
69
+ }
70
+
71
+ export function reportConversationCancellationFailure(
72
+ conversation: object,
73
+ error: unknown
74
+ ): boolean {
75
+ const handler = cancellationFailureHandlers.get(conversation);
76
+ if (handler === undefined) {
77
+ return false;
78
+ }
79
+ handler(error);
80
+ return true;
81
+ }
82
+
83
+ export function reserveConversationSettlement(conversation: object): () => void {
84
+ const reservation = reservations.get(conversation) ?? { depth: 0 };
85
+ reservation.depth += 1;
86
+ reservations.set(conversation, reservation);
87
+ let released = false;
88
+
89
+ return () => {
90
+ if (released) {
91
+ return;
92
+ }
93
+ released = true;
94
+ reservation.depth -= 1;
95
+ if (reservation.depth > 0) {
96
+ return;
97
+ }
98
+
99
+ reservations.delete(conversation);
100
+ const pending = reservation.pending;
101
+ delete reservation.pending;
102
+ pending?.publish();
103
+ };
104
+ }
105
+
106
+ export function isConversationSettlementReserved(conversation: object): boolean {
107
+ return reservations.has(conversation);
108
+ }
109
+
110
+ export function deferConversationSettlement(
111
+ conversation: object,
112
+ kind: SettlementKind,
113
+ publish: () => void
114
+ ): boolean {
115
+ const reservation = reservations.get(conversation);
116
+ if (reservation === undefined) {
117
+ return false;
118
+ }
119
+
120
+ if (
121
+ reservation.pending === undefined ||
122
+ settlementPriority[kind] > settlementPriority[reservation.pending.kind]
123
+ ) {
124
+ reservation.pending = { kind, publish };
125
+ }
126
+ return true;
127
+ }
@@ -4,6 +4,13 @@ import type { SessionId } from "./brand.ts";
4
4
 
5
5
  export type BackendApprovalPolicy = "never" | "on-request" | "on-failure" | "granular" | "untrusted";
6
6
  export type BackendSandboxMode = "read-only" | "workspace-write" | "danger-full-access";
7
+ export type CodexReasoningEffort =
8
+ | "low"
9
+ | "medium"
10
+ | "high"
11
+ | "xhigh"
12
+ | "max"
13
+ | "ultra";
7
14
 
8
15
  export interface BackendRetryConfig {
9
16
  readonly attempts: number;
@@ -17,6 +24,7 @@ export interface StructuredOutputConfig<Output = unknown> {
17
24
 
18
25
  export interface BackendConfig<B extends BackendTag = BackendTag, Output = unknown> {
19
26
  readonly model?: string;
27
+ readonly reasoningEffort?: B extends "codex" ? CodexReasoningEffort : never;
20
28
  readonly systemPrompt?: string;
21
29
  readonly approvalPolicy?: BackendApprovalPolicy;
22
30
  readonly readOnly?: boolean;