task-while 0.0.1 → 0.0.3

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 (42) hide show
  1. package/README.md +32 -34
  2. package/package.json +2 -2
  3. package/src/adapters/fs/harness-store.ts +84 -0
  4. package/src/agents/claude.ts +159 -9
  5. package/src/agents/codex.ts +68 -4
  6. package/src/agents/event-log.ts +195 -0
  7. package/src/batch/discovery.ts +1 -1
  8. package/src/batch/provider.ts +9 -0
  9. package/src/commands/batch.ts +69 -165
  10. package/src/commands/run-branch-helpers.ts +81 -0
  11. package/src/commands/run-providers.ts +77 -0
  12. package/src/commands/run.ts +117 -225
  13. package/src/core/create-runtime-ports.ts +118 -0
  14. package/src/core/runtime.ts +15 -36
  15. package/src/harness/in-memory-store.ts +45 -0
  16. package/src/harness/kernel.ts +226 -0
  17. package/src/harness/state.ts +47 -0
  18. package/src/harness/store.ts +26 -0
  19. package/src/harness/workflow-builders.ts +87 -0
  20. package/src/harness/workflow-program.ts +86 -0
  21. package/src/ports/agent.ts +17 -0
  22. package/src/ports/code-host.ts +23 -0
  23. package/src/programs/batch.ts +139 -0
  24. package/src/programs/run-direct.ts +209 -0
  25. package/src/programs/run-pr-transitions.ts +81 -0
  26. package/src/programs/run-pr.ts +290 -0
  27. package/src/programs/shared-steps.ts +252 -0
  28. package/src/schedulers/scheduler.ts +208 -0
  29. package/src/session/session.ts +127 -0
  30. package/src/workflow/config.ts +15 -0
  31. package/src/core/engine-helpers.ts +0 -114
  32. package/src/core/engine-outcomes.ts +0 -166
  33. package/src/core/engine.ts +0 -223
  34. package/src/core/orchestrator-helpers.ts +0 -52
  35. package/src/core/orchestrator-integrate-resume.ts +0 -149
  36. package/src/core/orchestrator-review-resume.ts +0 -228
  37. package/src/core/orchestrator-task-attempt.ts +0 -257
  38. package/src/core/orchestrator.ts +0 -99
  39. package/src/runtime/fs-runtime.ts +0 -209
  40. package/src/workflow/direct-preset.ts +0 -44
  41. package/src/workflow/preset.ts +0 -86
  42. package/src/workflow/pull-request-preset.ts +0 -312
@@ -0,0 +1,195 @@
1
+ import type { ClaudeAgentEvent, ClaudeAgentEventHandler } from './claude'
2
+ import type { CodexThreadEvent, CodexThreadEventHandler } from './codex'
3
+
4
+ function formatInline(value: string) {
5
+ return value.trim().replaceAll('\n', String.raw`\n`)
6
+ }
7
+
8
+ function formatJson(value: unknown) {
9
+ try {
10
+ return JSON.stringify(value)
11
+ } catch {
12
+ return String(value)
13
+ }
14
+ }
15
+
16
+ function writeCodexEvent(event: CodexThreadEvent) {
17
+ if (
18
+ event.type === 'item.completed' ||
19
+ event.type === 'item.started' ||
20
+ event.type === 'item.updated'
21
+ ) {
22
+ const item = event.item
23
+
24
+ if (item.type === 'reasoning') {
25
+ const text = formatInline(item.text)
26
+ if (text) {
27
+ process.stderr.write(`[codex] thinking ${text}\n`)
28
+ }
29
+ return
30
+ }
31
+
32
+ if (item.type === 'command_execution') {
33
+ if (event.type === 'item.started') {
34
+ process.stderr.write(`[codex] exec ${formatInline(item.command)}\n`)
35
+ return
36
+ }
37
+ if (event.type === 'item.completed') {
38
+ process.stderr.write(
39
+ `[codex] exec ${item.status} exit=${item.exit_code ?? 'unknown'} ${formatInline(item.command)}\n`,
40
+ )
41
+ const output = formatInline(item.aggregated_output)
42
+ if (output) {
43
+ process.stderr.write(`[codex] output ${output}\n`)
44
+ }
45
+ return
46
+ }
47
+ }
48
+
49
+ if (item.type === 'mcp_tool_call') {
50
+ const target = `${item.server}.${item.tool}`
51
+ if (event.type === 'item.started') {
52
+ process.stderr.write(
53
+ `[codex] tool ${target} ${formatJson(item.arguments)}\n`,
54
+ )
55
+ return
56
+ }
57
+ if (event.type === 'item.completed') {
58
+ const detail =
59
+ item.status === 'failed'
60
+ ? ` error=${item.error?.message ?? 'unknown'}`
61
+ : ''
62
+ process.stderr.write(`[codex] tool ${item.status} ${target}${detail}\n`)
63
+ return
64
+ }
65
+ }
66
+
67
+ if (item.type === 'file_change' && event.type === 'item.completed') {
68
+ const files = item.changes.map((change) => change.path).join(', ')
69
+ process.stderr.write(`[codex] files ${item.status} ${files}\n`)
70
+ return
71
+ }
72
+
73
+ if (item.type === 'web_search') {
74
+ process.stderr.write(`[codex] search ${formatInline(item.query)}\n`)
75
+ return
76
+ }
77
+
78
+ if (item.type === 'todo_list') {
79
+ for (const todo of item.items) {
80
+ process.stderr.write(
81
+ `[codex] todo ${todo.completed ? '[x]' : '[ ]'} ${formatInline(todo.text)}\n`,
82
+ )
83
+ }
84
+ return
85
+ }
86
+
87
+ if (item.type === 'error') {
88
+ process.stderr.write(`[codex] error ${formatInline(item.message)}\n`)
89
+ return
90
+ }
91
+
92
+ if (item.type === 'agent_message' && event.type === 'item.completed') {
93
+ const text = formatInline(item.text)
94
+ if (text) {
95
+ process.stderr.write(`[codex] message ${text}\n`)
96
+ }
97
+ return
98
+ }
99
+ }
100
+
101
+ if (event.type === 'turn.completed') {
102
+ process.stderr.write(
103
+ `[codex] result tokens in=${event.usage.input_tokens} out=${event.usage.output_tokens} cached=${event.usage.cached_input_tokens}\n`,
104
+ )
105
+ return
106
+ }
107
+
108
+ if (event.type === 'error') {
109
+ process.stderr.write(`[codex] error ${formatInline(event.message)}\n`)
110
+ return
111
+ }
112
+
113
+ if (event.type === 'turn.failed') {
114
+ process.stderr.write(`[codex] error ${formatInline(event.error.message)}\n`)
115
+ }
116
+ }
117
+
118
+ function writeClaudeEvent(event: ClaudeAgentEvent) {
119
+ if (event.type === 'system.init') {
120
+ const tools = event.tools.length !== 0 ? event.tools.join(',') : '-'
121
+ const skills = event.skills.length !== 0 ? event.skills.join(',') : '-'
122
+ const mcp =
123
+ event.mcpServers.length !== 0
124
+ ? event.mcpServers
125
+ .map((server) => `${server.name}:${server.status}`)
126
+ .join(',')
127
+ : '-'
128
+ process.stderr.write(
129
+ `[claude] init model=${event.model} permission=${event.permissionMode} tools=${tools} skills=${skills} mcp=${mcp}\n`,
130
+ )
131
+ return
132
+ }
133
+
134
+ if (event.type === 'task.started') {
135
+ process.stderr.write(`[claude] task ${formatInline(event.description)}\n`)
136
+ return
137
+ }
138
+
139
+ if (event.type === 'tool.progress') {
140
+ process.stderr.write(
141
+ `[claude] tool ${event.toolName} ${event.elapsedTimeSeconds}s\n`,
142
+ )
143
+ return
144
+ }
145
+
146
+ if (event.type === 'tool.summary') {
147
+ process.stderr.write(
148
+ `[claude] tool-summary ${formatInline(event.summary)}\n`,
149
+ )
150
+ return
151
+ }
152
+
153
+ if (event.type === 'task.progress') {
154
+ const detail = event.summary ?? event.description
155
+ process.stderr.write(
156
+ `[claude] progress ${formatInline(event.lastToolName ?? '-')} ${formatInline(detail)}\n`,
157
+ )
158
+ return
159
+ }
160
+
161
+ if (event.type === 'text') {
162
+ const text = formatInline(event.delta)
163
+ if (text) {
164
+ process.stderr.write(`[claude] text ${text}\n`)
165
+ }
166
+ return
167
+ }
168
+
169
+ if (event.type === 'result') {
170
+ process.stderr.write(
171
+ `[claude] result ${event.subtype} turns=${event.numTurns} duration=${event.durationMs}ms\n`,
172
+ )
173
+ return
174
+ }
175
+
176
+ process.stderr.write(`[claude] error ${formatInline(event.message)}\n`)
177
+ }
178
+
179
+ export function createCodexEventHandler(
180
+ verbose: boolean | undefined,
181
+ ): CodexThreadEventHandler | undefined {
182
+ if (!verbose) {
183
+ return undefined
184
+ }
185
+ return writeCodexEvent
186
+ }
187
+
188
+ export function createClaudeEventHandler(
189
+ verbose: boolean | undefined,
190
+ ): ClaudeAgentEventHandler | undefined {
191
+ if (!verbose) {
192
+ return undefined
193
+ }
194
+ return writeClaudeEvent
195
+ }
@@ -24,7 +24,7 @@ export async function discoverBatchFiles(
24
24
  absolute: false,
25
25
  cwd: input.baseDir,
26
26
  dot: true,
27
- ignore: ['.git/**', 'node_modules/**'],
27
+ ignore: ['.git/**', '.while/**', 'node_modules/**'],
28
28
  nodir: true,
29
29
  posix: true,
30
30
  })
@@ -1,5 +1,9 @@
1
1
  import { ClaudeAgentClient } from '../agents/claude'
2
2
  import { CodexAgentClient } from '../agents/codex'
3
+ import {
4
+ createClaudeEventHandler,
5
+ createCodexEventHandler,
6
+ } from '../agents/event-log'
3
7
 
4
8
  import type { WorkflowRoleProviderOptions } from '../agents/provider-options'
5
9
 
@@ -17,6 +21,7 @@ export interface BatchStructuredOutputProvider {
17
21
 
18
22
  export type CreateBatchStructuredOutputProviderInput =
19
23
  WorkflowRoleProviderOptions & {
24
+ verbose?: boolean
20
25
  workspaceRoot: string
21
26
  }
22
27
 
@@ -60,19 +65,23 @@ export function createBatchStructuredOutputProvider(
60
65
  input: CreateBatchStructuredOutputProviderInput,
61
66
  ): BatchStructuredOutputProvider {
62
67
  if (input.provider === 'codex') {
68
+ const onEvent = createCodexEventHandler(input.verbose)
63
69
  return new CodexBatchStructuredOutputProvider(
64
70
  new CodexAgentClient({
65
71
  ...(input.effort ? { effort: input.effort } : {}),
66
72
  ...(input.model ? { model: input.model } : {}),
73
+ ...(onEvent ? { onEvent } : {}),
67
74
  workspaceRoot: input.workspaceRoot,
68
75
  }),
69
76
  )
70
77
  }
71
78
 
79
+ const onEvent = createClaudeEventHandler(input.verbose)
72
80
  return new ClaudeBatchStructuredOutputProvider(
73
81
  new ClaudeAgentClient({
74
82
  ...(input.effort ? { effort: input.effort } : {}),
75
83
  ...(input.model ? { model: input.model } : {}),
84
+ ...(onEvent ? { onEvent } : {}),
76
85
  workspaceRoot: input.workspaceRoot,
77
86
  }),
78
87
  )
@@ -5,23 +5,21 @@ import Ajv from 'ajv'
5
5
  import * as fsExtra from 'fs-extra'
6
6
  import { z } from 'zod'
7
7
 
8
+ import { createFsHarnessStore } from '../adapters/fs/harness-store'
8
9
  import { loadBatchConfig, type BatchConfig } from '../batch/config'
9
10
  import { discoverBatchFiles } from '../batch/discovery'
10
11
  import {
11
12
  createBatchStructuredOutputProvider,
12
13
  type BatchStructuredOutputProvider,
13
14
  } from '../batch/provider'
14
- import { parseWithSchema, uniqueStringArray } from '../schema/shared'
15
+ import { runKernel } from '../harness/kernel'
16
+ import { createBatchProgram } from '../programs/batch'
17
+ import { createRuntimePaths } from '../runtime/path-layout'
18
+ import { createBatchRetryScheduler } from '../schedulers/scheduler'
19
+ import { parseWithSchema } from '../schema/shared'
20
+ import { runSession, SessionEventType } from '../session/session'
15
21
  import { writeJsonAtomic } from '../utils/fs'
16
22
 
17
- const batchStateSchema = z
18
- .object({
19
- failed: uniqueStringArray('failed'),
20
- inProgress: uniqueStringArray('inProgress'),
21
- pending: uniqueStringArray('pending'),
22
- })
23
- .strict()
24
-
25
23
  const batchResultsSchema = z.custom<Record<string, unknown>>(
26
24
  (value) =>
27
25
  typeof value === 'object' && value !== null && !Array.isArray(value),
@@ -30,12 +28,6 @@ const batchResultsSchema = z.custom<Record<string, unknown>>(
30
28
  },
31
29
  )
32
30
 
33
- export interface BatchState {
34
- failed: string[]
35
- inProgress: string[]
36
- pending: string[]
37
- }
38
-
39
31
  export interface RunBatchCommandInput {
40
32
  configPath: string
41
33
  cwd?: string
@@ -47,19 +39,7 @@ export interface RunBatchCommandResult {
47
39
  failedFiles: string[]
48
40
  processedFiles: string[]
49
41
  results: Record<string, unknown>
50
- state: BatchState
51
- }
52
-
53
- function createEmptyState(): BatchState {
54
- return {
55
- failed: [],
56
- inProgress: [],
57
- pending: [],
58
- }
59
- }
60
-
61
- function unique(items: string[]) {
62
- return [...new Set(items)]
42
+ resultsFilePath: string
63
43
  }
64
44
 
65
45
  async function readJsonFileIfExists(filePath: string) {
@@ -72,14 +52,6 @@ async function readJsonFileIfExists(filePath: string) {
72
52
  return value
73
53
  }
74
54
 
75
- async function loadBatchState(filePath: string) {
76
- const value = await readJsonFileIfExists(filePath)
77
- if (value === null) {
78
- return createEmptyState()
79
- }
80
- return parseWithSchema(batchStateSchema, value)
81
- }
82
-
83
55
  async function loadBatchResults(filePath: string) {
84
56
  const value = await readJsonFileIfExists(filePath)
85
57
  if (value === null) {
@@ -88,82 +60,16 @@ async function loadBatchResults(filePath: string) {
88
60
  return parseWithSchema(batchResultsSchema, value)
89
61
  }
90
62
 
91
- function mergeBatchState(input: {
92
- discoveredFiles: string[]
93
- results: Record<string, unknown>
94
- state: BatchState
95
- }): BatchState {
96
- const discovered = new Set(input.discoveredFiles)
97
- const completed = new Set(Object.keys(input.results))
98
- const failed = unique(input.state.failed).filter(
99
- (filePath) => discovered.has(filePath) && !completed.has(filePath),
100
- )
101
- const failedSet = new Set(failed)
102
- const pending = unique([
103
- ...input.state.inProgress,
104
- ...input.state.pending,
105
- ]).filter(
106
- (filePath) =>
107
- discovered.has(filePath) &&
108
- !completed.has(filePath) &&
109
- !failedSet.has(filePath),
110
- )
111
- const pendingSet = new Set(pending)
112
-
113
- for (const filePath of input.discoveredFiles) {
114
- if (
115
- completed.has(filePath) ||
116
- failedSet.has(filePath) ||
117
- pendingSet.has(filePath)
118
- ) {
119
- continue
120
- }
121
- pending.push(filePath)
122
- pendingSet.add(filePath)
123
- }
124
-
125
- return {
126
- failed,
127
- inProgress: [],
128
- pending,
129
- }
130
- }
131
-
132
- function removeFile(items: string[], filePath: string) {
133
- return items.filter((item) => item !== filePath)
134
- }
135
-
136
- function writeBatchFailure(filePath: string, error: unknown) {
137
- process.stderr.write(
138
- `[batch] failed ${filePath}: ${
139
- error instanceof Error ? error.message : String(error)
140
- }\n`,
141
- )
142
- }
143
-
144
- async function recycleFailedFiles(
145
- statePath: string,
146
- state: BatchState,
147
- ): Promise<BatchState> {
148
- if (state.pending.length !== 0 || state.failed.length === 0) {
149
- return state
150
- }
151
-
152
- const nextState: BatchState = {
153
- failed: [],
154
- inProgress: [],
155
- pending: [...state.failed],
156
- }
157
- await writeJsonAtomic(statePath, nextState)
158
- return nextState
159
- }
160
-
161
- function createProvider(config: BatchConfig): BatchStructuredOutputProvider {
63
+ function createProvider(
64
+ config: BatchConfig,
65
+ verbose: boolean | undefined,
66
+ ): BatchStructuredOutputProvider {
162
67
  if (config.provider === 'codex') {
163
68
  return createBatchStructuredOutputProvider({
164
69
  provider: 'codex',
165
70
  ...(config.effort ? { effort: config.effort } : {}),
166
71
  ...(config.model ? { model: config.model } : {}),
72
+ ...(verbose === undefined ? {} : { verbose }),
167
73
  workspaceRoot: config.configDir,
168
74
  })
169
75
  }
@@ -172,10 +78,22 @@ function createProvider(config: BatchConfig): BatchStructuredOutputProvider {
172
78
  provider: 'claude',
173
79
  ...(config.effort ? { effort: config.effort } : {}),
174
80
  ...(config.model ? { model: config.model } : {}),
81
+ ...(verbose === undefined ? {} : { verbose }),
175
82
  workspaceRoot: config.configDir,
176
83
  })
177
84
  }
178
85
 
86
+ function createOutputValidator(schema: Record<string, unknown>) {
87
+ const ajv = new Ajv({ strict: false })
88
+ const validate = ajv.compile(schema)
89
+ return (value: unknown) => {
90
+ if (validate(value)) {
91
+ return
92
+ }
93
+ throw new Error(ajv.errorsText(validate.errors))
94
+ }
95
+ }
96
+
179
97
  export async function runBatchCommand(
180
98
  input: RunBatchCommandInput,
181
99
  ): Promise<RunBatchCommandResult> {
@@ -185,82 +103,68 @@ export async function runBatchCommand(
185
103
  cwd,
186
104
  })
187
105
 
188
- const statePath = path.join(config.configDir, 'state.json')
189
106
  const resultsPath = path.join(config.configDir, 'results.json')
190
- const excludedFiles = new Set([config.configPath, statePath, resultsPath])
107
+ const excludedFiles = new Set([config.configPath, resultsPath])
191
108
  const discoveredFiles = await discoverBatchFiles({
192
109
  baseDir: config.configDir,
193
110
  excludedFiles,
194
111
  patterns: config.glob,
195
112
  })
196
113
  const results = await loadBatchResults(resultsPath)
197
- let state: BatchState = mergeBatchState({
198
- discoveredFiles,
114
+ await writeJsonAtomic(resultsPath, results)
115
+
116
+ const provider = createProvider(config, input.verbose)
117
+ const validateOutput = createOutputValidator(config.schema)
118
+ const harnessDir = createRuntimePaths(config.configDir).runtimeDir
119
+ const store = createFsHarnessStore(harnessDir)
120
+ const protocol = 'batch'
121
+
122
+ const program = createBatchProgram({
123
+ configDir: config.configDir,
124
+ maxRetries: 3,
125
+ outputSchema: config.schema,
126
+ prompt: config.prompt,
127
+ provider,
199
128
  results,
200
- state: await loadBatchState(statePath),
129
+ resultsPath,
130
+ validateOutput,
201
131
  })
202
- await writeJsonAtomic(statePath, state)
203
- await writeJsonAtomic(resultsPath, results)
204
- const ajv = new Ajv({
205
- allErrors: true,
206
- strict: false,
132
+
133
+ const scheduler = createBatchRetryScheduler({
134
+ files: discoveredFiles,
135
+ protocol,
136
+ results,
137
+ store,
207
138
  })
208
- const validateOutput = ajv.compile(config.schema)
209
- const processedFiles: string[] = []
210
- let provider: BatchStructuredOutputProvider | null = null
211
139
 
212
- while (state.pending.length !== 0 || state.failed.length !== 0) {
213
- if (state.pending.length === 0) {
214
- state = await recycleFailedFiles(statePath, state)
215
- continue
216
- }
217
- const filePath = state.pending[0]!
218
- state = {
219
- ...state,
220
- inProgress: unique([...state.inProgress, filePath]),
221
- pending: state.pending.slice(1),
222
- }
223
- await writeJsonAtomic(statePath, state)
140
+ const processedFiles: string[] = []
224
141
 
225
- try {
226
- provider ??= createProvider(config)
227
- const absoluteFilePath = path.join(config.configDir, filePath)
228
- const content = await readFile(absoluteFilePath, 'utf8')
229
- const output = await provider.runFile({
230
- content,
231
- filePath,
232
- outputSchema: config.schema,
233
- prompt: config.prompt,
234
- })
235
- if (!validateOutput(output)) {
236
- throw new Error(ajv.errorsText(validateOutput.errors))
237
- }
238
- results[filePath] = output
239
- await writeJsonAtomic(resultsPath, results)
240
- state = {
241
- ...state,
242
- inProgress: removeFile(state.inProgress, filePath),
243
- }
244
- await writeJsonAtomic(statePath, state)
245
- processedFiles.push(filePath)
246
- } catch (error) {
247
- if (input.verbose) {
248
- writeBatchFailure(filePath, error)
249
- }
250
- state = {
251
- failed: unique([...state.failed, filePath]),
252
- inProgress: removeFile(state.inProgress, filePath),
253
- pending: state.pending,
254
- }
255
- await writeJsonAtomic(statePath, state)
142
+ for await (const event of runSession({
143
+ config: {},
144
+ scheduler,
145
+ kernel: {
146
+ run: (subjectId) =>
147
+ runKernel({
148
+ config: { prompt: config.prompt, schema: config.schema },
149
+ program,
150
+ protocol,
151
+ store,
152
+ subjectId,
153
+ }),
154
+ },
155
+ })) {
156
+ if (event.type === SessionEventType.SubjectDone) {
157
+ processedFiles.push(event.subjectId)
256
158
  }
257
159
  }
258
160
 
161
+ const sets = await scheduler.rebuild()
162
+
259
163
  return {
260
164
  config,
261
- failedFiles: state.failed,
165
+ failedFiles: [...sets.blocked],
262
166
  processedFiles,
263
167
  results,
264
- state,
168
+ resultsFilePath: resultsPath,
265
169
  }
266
170
  }
@@ -0,0 +1,81 @@
1
+ import type { GitPort } from '../core/runtime'
2
+ import type { CodeHostPort } from '../ports/code-host'
3
+ import type { TaskSourceSession } from '../task-sources/types'
4
+
5
+ export const sleep = (ms: number) =>
6
+ new Promise<void>((resolve) => setTimeout(resolve, ms))
7
+
8
+ export function toTaskBranchName(commitSubject: string) {
9
+ const slug = commitSubject
10
+ .replace(/^Task\s+/i, '')
11
+ .toLowerCase()
12
+ .replace(/[^a-z0-9]+/g, '-')
13
+ .replace(/^-+|-+$/g, '')
14
+ return `task/${slug}`
15
+ }
16
+
17
+ export async function ensureTaskBranch(
18
+ git: GitPort,
19
+ branchName: string,
20
+ restoreFromRemote: boolean,
21
+ ) {
22
+ const currentBranch = await git.getCurrentBranch()
23
+ if (currentBranch === branchName) {
24
+ return
25
+ }
26
+ try {
27
+ await git.checkoutBranch(branchName)
28
+ } catch {
29
+ if (restoreFromRemote) {
30
+ await git.checkoutRemoteBranch(branchName)
31
+ return
32
+ }
33
+ await git.checkoutBranch(branchName, {
34
+ create: true,
35
+ startPoint: 'main',
36
+ })
37
+ }
38
+ }
39
+
40
+ export async function runPrCheckpoint(
41
+ ports: { codeHost: CodeHostPort; git: GitPort },
42
+ taskSource: TaskSourceSession,
43
+ input: { iteration: number; subjectId: string },
44
+ ): Promise<{ checkpointStartedAt: string; prNumber: number }> {
45
+ const commitSubject = taskSource.buildCommitSubject(input.subjectId)
46
+ const branchName = toTaskBranchName(commitSubject)
47
+ const existingPr = await ports.codeHost.findOpenPullRequestByHeadBranch({
48
+ headBranch: branchName,
49
+ })
50
+
51
+ await ensureTaskBranch(ports.git, branchName, existingPr !== null)
52
+
53
+ const checkpointMessage = `checkpoint: ${commitSubject} (attempt ${input.iteration})`
54
+ const headSubject = await ports.git.getHeadSubject()
55
+ if (headSubject !== checkpointMessage) {
56
+ await ports.git.commitTask({ message: checkpointMessage })
57
+ }
58
+
59
+ await ports.git.pushBranch(branchName)
60
+
61
+ let pullRequest = existingPr
62
+ if (!pullRequest) {
63
+ pullRequest = await ports.codeHost.createPullRequest({
64
+ baseBranch: 'main',
65
+ body: `Task: ${commitSubject}\nManaged by task-while.`,
66
+ headBranch: branchName,
67
+ title: commitSubject,
68
+ })
69
+ }
70
+
71
+ const checkpointStartedAt = await ports.git.getHeadTimestamp()
72
+ return { checkpointStartedAt, prNumber: pullRequest.number }
73
+ }
74
+
75
+ export async function cleanupBranch(git: GitPort, branchName: string) {
76
+ try {
77
+ await git.checkoutBranch('main')
78
+ await git.pullFastForward('main')
79
+ await git.deleteLocalBranch(branchName)
80
+ } catch {}
81
+ }