task-while 0.0.4 → 0.0.5

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.
package/README.md CHANGED
@@ -40,7 +40,7 @@ pnpm exec task-while run
40
40
 
41
41
  ## Configuration
42
42
 
43
- `while.yaml` configures the `run` workflow only. When it is absent, the CLI runs `task.source: spec-kit`, `task.maxIterations: 5`, and `workflow.mode: direct` with `codex` for both roles. Each workflow role accepts provider-specific `model` and `effort`.
43
+ `while.yaml` configures the `run` workflow only. When it is absent, the CLI runs `task.source: spec-kit`, `task.maxIterations: 5`, and `workflow.mode: direct` with `codex` for both roles. Each workflow role accepts provider-specific `model`, `effort`, and optional `timeout` in milliseconds.
44
44
 
45
45
  ```yaml
46
46
  task:
@@ -53,20 +53,23 @@ workflow:
53
53
  implementer:
54
54
  model: gpt-5-codex
55
55
  effort: high
56
+ timeout: 900000
56
57
  reviewer:
57
58
  model: gpt-5-codex
58
59
  effort: high
60
+ timeout: 900000
59
61
  ```
60
62
 
61
63
  Current status:
62
64
 
63
65
  - `workflow.roles.<role>.provider` accepts `codex` or `claude`; when omitted it defaults to `codex`, including roles that only set `model` and/or `effort`
66
+ - `workflow.roles.<role>.timeout` is optional and sets a per-turn timeout in milliseconds for local agent runs; valid values are positive integers up to `2147483647`
64
67
  - `codex` `effort` accepts `minimal`, `low`, `medium`, `high`, or `xhigh`
65
68
  - `claude` `effort` accepts `low`, `medium`, `high`, or `max`
66
69
  - `workflow.mode: direct` requires `implementer` and `reviewer` to use identical `model` and `effort` when they share the same provider
67
70
  - `workflow.mode: direct` uses a local reviewer
68
71
  - `workflow.mode: pull-request` pushes a task branch, polls GitHub PR review from `chatgpt-codex-connector[bot]`, then squash-merges on approval
69
- - in `workflow.mode: pull-request`, reviewer `provider` still selects the remote reviewer, but any local reviewer `model` and `effort` values are ignored
72
+ - in `workflow.mode: pull-request`, reviewer `provider` still selects the remote reviewer, but any local reviewer `model`, `effort`, and `timeout` values are ignored
70
73
  - `workflow.mode: pull-request` currently supports only `codex` as the remote reviewer provider
71
74
  - `task.maxIterations` uses the same configured limit for every task in the selected source session; run workflow retries share a single per-task budget across phases
72
75
 
@@ -135,6 +138,7 @@ Batch config example:
135
138
  provider: claude
136
139
  model: claude-sonnet-4-6
137
140
  effort: max
141
+ timeout: 300000
138
142
  glob:
139
143
  - 'src/**/*.{ts,tsx}'
140
144
  prompt: |
@@ -157,10 +161,11 @@ Batch behavior:
157
161
  - `glob` is optional and defaults to `**/*`
158
162
  - `glob` is resolved relative to the directory that contains `batch.yaml`
159
163
  - `provider`, `prompt`, and `schema` are required
160
- - `model` and `effort` are optional and are forwarded to the selected provider client
164
+ - `model`, `effort`, and `timeout` are optional and are forwarded to the selected provider client
161
165
  - batch `provider` accepts `codex` or `claude`
162
166
  - batch `codex` `effort` accepts `minimal`, `low`, `medium`, `high`, or `xhigh`
163
167
  - batch `claude` `effort` accepts `low`, `medium`, `high`, or `max`
168
+ - batch `timeout` is an optional per-file timeout in milliseconds; valid values are positive integers up to `2147483647`
164
169
  - each run scans files under the `batch.yaml` directory and filters them by `glob`
165
170
  - structured results are written beside the YAML file in `results.json`
166
171
  - internal harness state is written under `.while/harness/` beside the YAML file
File without changes
package/package.json CHANGED
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "name": "task-while",
3
- "version": "0.0.4",
4
- "packageManager": "pnpm@10.32.1",
3
+ "version": "0.0.5",
5
4
  "description": "Git-first task orchestrator for task-source workspaces",
6
5
  "author": "Zhang Yu",
7
6
  "license": "MIT",
@@ -32,23 +31,9 @@
32
31
  "engines": {
33
32
  "node": ">=24"
34
33
  },
35
- "scripts": {
36
- "dev": "tsx src/index.ts",
37
- "format": "prettier . --write",
38
- "format:check": "prettier . --check",
39
- "lint": "eslint .",
40
- "lint:fix": "eslint . --fix",
41
- "smoke:codex": "tsx fixtures/smoke/codex-provider.ts",
42
- "smoke:e2e:codex": "tsx fixtures/smoke/codex-e2e.ts",
43
- "smoke:github-pr-snapshot": "tsx fixtures/smoke/github-pr-snapshot.ts",
44
- "typecheck": "tsc --noEmit",
45
- "test": "vitest run",
46
- "test:watch": "vitest",
47
- "coverage": "vitest run --coverage"
48
- },
49
34
  "dependencies": {
50
- "@anthropic-ai/claude-agent-sdk": "^0.2.92",
51
- "@openai/codex-sdk": "^0.118.0",
35
+ "@anthropic-ai/claude-agent-sdk": "^0.2.104",
36
+ "@openai/codex-sdk": "^0.120.0",
52
37
  "ajv": "^8.18.0",
53
38
  "arg": "^5.0.2",
54
39
  "execa": "^8.0.1",
@@ -68,5 +53,19 @@
68
53
  "prettier": "^3.8.1",
69
54
  "typescript": "^5.9.3",
70
55
  "vitest": "4.1.0"
56
+ },
57
+ "scripts": {
58
+ "dev": "tsx src/index.ts",
59
+ "format": "prettier . --write",
60
+ "format:check": "prettier . --check",
61
+ "lint": "eslint .",
62
+ "lint:fix": "eslint . --fix",
63
+ "smoke:codex": "tsx fixtures/smoke/codex-provider.ts",
64
+ "smoke:e2e:codex": "tsx fixtures/smoke/codex-e2e.ts",
65
+ "smoke:github-pr-snapshot": "tsx fixtures/smoke/github-pr-snapshot.ts",
66
+ "typecheck": "tsc --noEmit",
67
+ "test": "vitest run",
68
+ "test:watch": "vitest",
69
+ "coverage": "vitest run --coverage"
71
70
  }
72
- }
71
+ }
@@ -6,6 +6,7 @@ import {
6
6
  validateImplementOutput,
7
7
  validateReviewOutput,
8
8
  } from '../schema/index'
9
+ import { withAbortTimeout } from './timeout'
9
10
 
10
11
  import type { Options as ClaudeQueryOptions } from '@anthropic-ai/claude-agent-sdk'
11
12
 
@@ -278,34 +279,41 @@ export class ClaudeAgentClient
278
279
 
279
280
  public async invokeStructured<T>(input: ClaudeStructuredInput): Promise<T> {
280
281
  const { query } = await import('@anthropic-ai/claude-agent-sdk')
281
- const queryOptions = {
282
- allowDangerouslySkipPermissions: true,
283
- cwd: this.options.workspaceRoot,
284
- permissionMode: 'bypassPermissions',
285
- outputFormat: {
286
- schema: input.outputSchema,
287
- type: 'json_schema',
288
- },
289
- ...(this.options.onEvent
290
- ? {
291
- agentProgressSummaries: true,
292
- includePartialMessages: true,
293
- }
294
- : {
295
- includePartialMessages: false,
296
- }),
297
- ...(this.options.model ? { model: this.options.model } : {}),
298
- ...(this.options.effort ? { effort: this.options.effort } : {}),
299
- } satisfies ClaudeQueryOptions
300
-
301
- const messages = query({
302
- options: queryOptions,
303
- prompt: input.prompt,
304
- })
282
+ return withAbortTimeout(
283
+ this.name,
284
+ this.options.timeout,
285
+ async (controller) => {
286
+ const queryOptions = {
287
+ allowDangerouslySkipPermissions: true,
288
+ cwd: this.options.workspaceRoot,
289
+ permissionMode: 'bypassPermissions',
290
+ outputFormat: {
291
+ schema: input.outputSchema,
292
+ type: 'json_schema',
293
+ },
294
+ ...(this.options.onEvent
295
+ ? {
296
+ agentProgressSummaries: true,
297
+ includePartialMessages: true,
298
+ }
299
+ : {
300
+ includePartialMessages: false,
301
+ }),
302
+ ...(this.options.model ? { model: this.options.model } : {}),
303
+ ...(this.options.effort ? { effort: this.options.effort } : {}),
304
+ ...(controller ? { abortController: controller } : {}),
305
+ } satisfies ClaudeQueryOptions
306
+
307
+ const messages = query({
308
+ options: queryOptions,
309
+ prompt: input.prompt,
310
+ })
305
311
 
306
- return this.collectStructuredOutput(
307
- messages as AsyncIterable<QueryMessage>,
308
- ) as Promise<T>
312
+ return this.collectStructuredOutput(
313
+ messages as AsyncIterable<QueryMessage>,
314
+ ) as Promise<T>
315
+ },
316
+ )
309
317
  }
310
318
 
311
319
  public async review(input: ReviewAgentInput) {
@@ -6,6 +6,7 @@ import {
6
6
  validateImplementOutput,
7
7
  validateReviewOutput,
8
8
  } from '../schema/index'
9
+ import { withAbortTimeout } from './timeout'
9
10
 
10
11
  import type { CodexProviderOptions } from './provider-options'
11
12
  import type {
@@ -143,6 +144,7 @@ export interface CodexRunStreamedResult {
143
144
 
144
145
  export interface CodexThreadRunOptions {
145
146
  outputSchema: Record<string, unknown>
147
+ signal?: AbortSignal
146
148
  }
147
149
 
148
150
  export interface CodexThreadLike {
@@ -190,9 +192,11 @@ export class CodexAgentClient implements ImplementerProvider, ReviewerProvider {
190
192
  private async collectStreamedTurn<T>(
191
193
  thread: CodexThreadLike,
192
194
  input: CodexStructuredInput,
195
+ signal?: AbortSignal,
193
196
  ): Promise<T> {
194
197
  const streamedTurn = await thread.runStreamed!(input.prompt, {
195
198
  outputSchema: input.outputSchema,
199
+ ...(signal ? { signal } : {}),
196
200
  })
197
201
  let finalResponse = ''
198
202
 
@@ -259,23 +263,34 @@ export class CodexAgentClient implements ImplementerProvider, ReviewerProvider {
259
263
  const thread = client.startThread(startThreadOptions)
260
264
 
261
265
  if (this.options.onEvent && thread.runStreamed) {
262
- return this.collectStreamedTurn<T>(thread, input)
263
- }
264
-
265
- const turn = await thread.run(input.prompt, {
266
- outputSchema: input.outputSchema,
267
- })
268
- const response = turn.finalResponse.trim()
269
- if (!response) {
270
- throw new Error('Codex agent client returned empty finalResponse')
271
- }
272
- try {
273
- return JSON.parse(response) as T
274
- } catch (error) {
275
- throw new Error('Codex agent client returned non-JSON finalResponse', {
276
- cause: error,
277
- })
266
+ return withAbortTimeout(this.name, this.options.timeout, (controller) =>
267
+ this.collectStreamedTurn<T>(thread, input, controller?.signal),
268
+ )
278
269
  }
270
+ return withAbortTimeout(
271
+ this.name,
272
+ this.options.timeout,
273
+ async (controller) => {
274
+ const turn = await thread.run(input.prompt, {
275
+ outputSchema: input.outputSchema,
276
+ ...(controller ? { signal: controller.signal } : {}),
277
+ })
278
+ const response = turn.finalResponse.trim()
279
+ if (!response) {
280
+ throw new Error('Codex agent client returned empty finalResponse')
281
+ }
282
+ try {
283
+ return JSON.parse(response) as T
284
+ } catch (error) {
285
+ throw new Error(
286
+ 'Codex agent client returned non-JSON finalResponse',
287
+ {
288
+ cause: error,
289
+ },
290
+ )
291
+ }
292
+ },
293
+ )
279
294
  }
280
295
 
281
296
  public async review(input: ReviewAgentInput) {
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod'
2
2
 
3
3
  const modelSchema = z.string().trim().min(1)
4
+ const timeoutSchema = z.number().int().positive().max(2_147_483_647)
4
5
 
5
6
  export const codexEffortSchema = z.enum([
6
7
  'minimal',
@@ -16,6 +17,7 @@ export const codexProviderOptionsSchema = z
16
17
  .object({
17
18
  effort: codexEffortSchema.optional(),
18
19
  model: modelSchema.optional(),
20
+ timeout: timeoutSchema.optional(),
19
21
  })
20
22
  .strict()
21
23
 
@@ -23,6 +25,7 @@ export const claudeProviderOptionsSchema = z
23
25
  .object({
24
26
  effort: claudeEffortSchema.optional(),
25
27
  model: modelSchema.optional(),
28
+ timeout: timeoutSchema.optional(),
26
29
  })
27
30
  .strict()
28
31
 
@@ -43,3 +46,17 @@ export function providerOptionsEqual(
43
46
  left.effort === right.effort
44
47
  )
45
48
  }
49
+
50
+ export function providerOptionsCacheKey(options: {
51
+ effort?: string | undefined
52
+ model?: string | undefined
53
+ provider: 'claude' | 'codex'
54
+ timeout?: number | undefined
55
+ }) {
56
+ return [
57
+ options.provider,
58
+ options.model ?? '',
59
+ options.effort ?? '',
60
+ String(options.timeout ?? ''),
61
+ ].join(':')
62
+ }
@@ -0,0 +1,41 @@
1
+ export class AgentTimeoutError extends Error {
2
+ public constructor(
3
+ public readonly provider: string,
4
+ public readonly timeout: number,
5
+ options?: ErrorOptions,
6
+ ) {
7
+ super(`${provider} agent timed out after ${timeout}ms`, options)
8
+ }
9
+ }
10
+
11
+ export async function withAbortTimeout<T>(
12
+ provider: string,
13
+ timeout: number | undefined,
14
+ run: (controller: AbortController | undefined) => Promise<T>,
15
+ ): Promise<T> {
16
+ if (!timeout) {
17
+ return run(undefined)
18
+ }
19
+
20
+ const controller = new AbortController()
21
+ const state = { timedOut: false }
22
+ const timer = setTimeout(() => {
23
+ state.timedOut = true
24
+ controller.abort()
25
+ }, timeout)
26
+
27
+ try {
28
+ const result = await run(controller)
29
+ if (state.timedOut) {
30
+ throw new AgentTimeoutError(provider, timeout)
31
+ }
32
+ return result
33
+ } catch (error) {
34
+ if (state.timedOut) {
35
+ throw new AgentTimeoutError(provider, timeout, { cause: error })
36
+ }
37
+ throw error
38
+ } finally {
39
+ clearTimeout(timer)
40
+ }
41
+ }
@@ -97,6 +97,7 @@ export async function loadBatchConfig(
97
97
  provider: 'claude',
98
98
  ...(parsedConfig.model ? { model: parsedConfig.model } : {}),
99
99
  ...(parsedConfig.effort ? { effort: parsedConfig.effort } : {}),
100
+ ...(parsedConfig.timeout ? { timeout: parsedConfig.timeout } : {}),
100
101
  }
101
102
  }
102
103
 
@@ -105,5 +106,6 @@ export async function loadBatchConfig(
105
106
  provider: 'codex',
106
107
  ...(parsedConfig.model ? { model: parsedConfig.model } : {}),
107
108
  ...(parsedConfig.effort ? { effort: parsedConfig.effort } : {}),
109
+ ...(parsedConfig.timeout ? { timeout: parsedConfig.timeout } : {}),
108
110
  }
109
111
  }
@@ -70,6 +70,7 @@ export function createBatchStructuredOutputProvider(
70
70
  new CodexAgentClient({
71
71
  ...(input.effort ? { effort: input.effort } : {}),
72
72
  ...(input.model ? { model: input.model } : {}),
73
+ ...(input.timeout ? { timeout: input.timeout } : {}),
73
74
  ...(onEvent ? { onEvent } : {}),
74
75
  workspaceRoot: input.workspaceRoot,
75
76
  }),
@@ -81,6 +82,7 @@ export function createBatchStructuredOutputProvider(
81
82
  new ClaudeAgentClient({
82
83
  ...(input.effort ? { effort: input.effort } : {}),
83
84
  ...(input.model ? { model: input.model } : {}),
85
+ ...(input.timeout ? { timeout: input.timeout } : {}),
84
86
  ...(onEvent ? { onEvent } : {}),
85
87
  workspaceRoot: input.workspaceRoot,
86
88
  }),
@@ -101,6 +101,7 @@ function createProvider(
101
101
  provider: 'codex',
102
102
  ...(config.effort ? { effort: config.effort } : {}),
103
103
  ...(config.model ? { model: config.model } : {}),
104
+ ...(config.timeout ? { timeout: config.timeout } : {}),
104
105
  ...(verbose === undefined ? {} : { verbose }),
105
106
  workspaceRoot: config.configDir,
106
107
  })
@@ -110,6 +111,7 @@ function createProvider(
110
111
  provider: 'claude',
111
112
  ...(config.effort ? { effort: config.effort } : {}),
112
113
  ...(config.model ? { model: config.model } : {}),
114
+ ...(config.timeout ? { timeout: config.timeout } : {}),
113
115
  ...(verbose === undefined ? {} : { verbose }),
114
116
  workspaceRoot: config.configDir,
115
117
  })
@@ -4,6 +4,7 @@ import {
4
4
  createClaudeEventHandler,
5
5
  createCodexEventHandler,
6
6
  } from '../agents/event-log'
7
+ import { providerOptionsCacheKey } from '../agents/provider-options'
7
8
  import { createCodexRemoteReviewerProvider } from '../workflow/remote-reviewer'
8
9
 
9
10
  import type {
@@ -26,12 +27,10 @@ export function createProviderResolver(
26
27
  context: WorkspaceContext,
27
28
  verbose: boolean | undefined,
28
29
  ): ProviderResolver {
29
- const cache = new Map<
30
- WorkflowProvider,
31
- ImplementerProvider & ReviewerProvider
32
- >()
30
+ const cache = new Map<string, ImplementerProvider & ReviewerProvider>()
33
31
  return (role: WorkflowRoleConfig) => {
34
- const cached = cache.get(role.provider)
32
+ const cacheKey = providerOptionsCacheKey(role)
33
+ const cached = cache.get(cacheKey)
35
34
  if (cached) {
36
35
  return cached
37
36
  }
@@ -41,6 +40,7 @@ export function createProviderResolver(
41
40
  provider = createClaudeProvider({
42
41
  ...(role.effort ? { effort: role.effort } : {}),
43
42
  ...(role.model ? { model: role.model } : {}),
43
+ ...(role.timeout ? { timeout: role.timeout } : {}),
44
44
  workspaceRoot: context.workspaceRoot,
45
45
  ...(onEvent ? { onEvent } : {}),
46
46
  })
@@ -49,11 +49,12 @@ export function createProviderResolver(
49
49
  provider = createCodexProvider({
50
50
  ...(role.effort ? { effort: role.effort } : {}),
51
51
  ...(role.model ? { model: role.model } : {}),
52
+ ...(role.timeout ? { timeout: role.timeout } : {}),
52
53
  workspaceRoot: context.workspaceRoot,
53
54
  ...(onEvent ? { onEvent } : {}),
54
55
  })
55
56
  }
56
- cache.set(role.provider, provider)
57
+ cache.set(cacheKey, provider)
57
58
  return provider
58
59
  }
59
60
  }
@@ -10,6 +10,7 @@ import {
10
10
  createClaudeEventHandler,
11
11
  createCodexEventHandler,
12
12
  } from '../agents/event-log'
13
+ import { providerOptionsCacheKey } from '../agents/provider-options'
13
14
  import { GitRuntime } from '../runtime/git'
14
15
  import { GitHubRuntime } from '../runtime/github'
15
16
  import { createRuntimePaths } from '../runtime/path-layout'
@@ -54,7 +55,7 @@ export function createRuntimePorts(
54
55
  const agentCache = new Map<string, AgentPort>()
55
56
 
56
57
  function resolveAgent(role: AgentRoleConfig): AgentPort {
57
- const key = `${role.provider}:${role.model ?? ''}:${role.effort ?? ''}`
58
+ const key = providerOptionsCacheKey(role)
58
59
  const cached = agentCache.get(key)
59
60
  if (cached) {
60
61
  return cached
@@ -72,6 +73,7 @@ export function createRuntimePorts(
72
73
  }
73
74
  : {}),
74
75
  ...(role.model ? { model: role.model } : {}),
76
+ ...(role.timeout ? { timeout: role.timeout } : {}),
75
77
  workspaceRoot: context.workspaceRoot,
76
78
  ...(onEvent ? { onEvent } : {}),
77
79
  })
@@ -86,6 +88,7 @@ export function createRuntimePorts(
86
88
  }
87
89
  : {}),
88
90
  ...(role.model ? { model: role.model } : {}),
91
+ ...(role.timeout ? { timeout: role.timeout } : {}),
89
92
  workspaceRoot: context.workspaceRoot,
90
93
  ...(onEvent ? { onEvent } : {}),
91
94
  })
@@ -138,6 +138,7 @@ export interface AgentRoleConfig {
138
138
  effort?: string | undefined
139
139
  model?: string | undefined
140
140
  provider: 'claude' | 'codex'
141
+ timeout?: number | undefined
141
142
  }
142
143
 
143
144
  export interface OrchestratorRuntime {