lalph 0.3.37 → 0.3.39

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.
@@ -5,17 +5,21 @@ import {
5
5
  Effect,
6
6
  FiberSet,
7
7
  FileSystem,
8
+ identity,
8
9
  Iterable,
9
10
  Option,
10
11
  Path,
12
+ PlatformError,
13
+ Schema,
14
+ Scope,
11
15
  Semaphore,
12
16
  Stream,
13
17
  } from "effect"
14
18
  import { PromptGen } from "../PromptGen.ts"
15
19
  import { Prd } from "../Prd.ts"
16
20
  import { Worktree } from "../Worktree.ts"
17
- import { Flag, Command } from "effect/unstable/cli"
18
- import { IssueSource } from "../IssueSource.ts"
21
+ import { Flag, Command, Prompt } from "effect/unstable/cli"
22
+ import { IssueSource, IssueSourceError } from "../IssueSource.ts"
19
23
  import {
20
24
  checkForWork,
21
25
  CurrentIssueSource,
@@ -24,7 +28,7 @@ import {
24
28
  } from "../CurrentIssueSource.ts"
25
29
  import { GithubCli } from "../Github/Cli.ts"
26
30
  import { agentWorker } from "../Agents/worker.ts"
27
- import { agentChooser } from "../Agents/chooser.ts"
31
+ import { agentChooser, ChosenTaskNotFound } from "../Agents/chooser.ts"
28
32
  import { RunnerStalled, TaskStateChanged } from "../domain/Errors.ts"
29
33
  import { agentReviewer } from "../Agents/reviewer.ts"
30
34
  import { agentTimeout } from "../Agents/timeout.ts"
@@ -36,10 +40,17 @@ import {
36
40
  withWorkerState,
37
41
  } from "../Workers.ts"
38
42
  import { WorkerStatus } from "../domain/WorkerState.ts"
39
- import { GitFlow, GitFlowCommit, GitFlowPR } from "../GitFlow.ts"
43
+ import { GitFlow, GitFlowCommit, GitFlowError, GitFlowPR } from "../GitFlow.ts"
40
44
  import { getAllProjects, welcomeWizard } from "../Projects.ts"
41
45
  import type { Project } from "../domain/Project.ts"
42
46
  import { getDefaultCliAgentPreset } from "../Presets.ts"
47
+ import { ChosenTaskDeferred } from "../TaskTools.ts"
48
+ import { ClankaModels, type ClankaModel } from "../ClankaModels.ts"
49
+ import { runClanka } from "../Clanka.ts"
50
+ import type { QuitError } from "effect/Terminal"
51
+ import type { TimeoutError } from "effect/Cause"
52
+ import type { ChildProcessSpawner } from "effect/unstable/process"
53
+ import type { AiError } from "effect/unstable/ai"
43
54
 
44
55
  // Main iteration run logic
45
56
 
@@ -51,7 +62,31 @@ const run = Effect.fnUntraced(
51
62
  readonly stallTimeout: Duration.Duration
52
63
  readonly runTimeout: Duration.Duration
53
64
  readonly review: boolean
54
- }) {
65
+ }): Effect.fn.Return<
66
+ void,
67
+ | PlatformError.PlatformError
68
+ | Schema.SchemaError
69
+ | IssueSourceError
70
+ | QuitError
71
+ | GitFlowError
72
+ | ChosenTaskNotFound
73
+ | RunnerStalled
74
+ | TimeoutError,
75
+ | CurrentProjectId
76
+ | ChildProcessSpawner.ChildProcessSpawner
77
+ | Settings
78
+ | Reactivity.Reactivity
79
+ | GithubCli
80
+ | IssueSource
81
+ | Prompt.Environment
82
+ | AtomRegistry.AtomRegistry
83
+ | GitFlow
84
+ | CurrentWorkerState
85
+ | PromptGen
86
+ | Prd
87
+ | Worktree
88
+ | Scope.Scope
89
+ > {
55
90
  const projectId = yield* CurrentProjectId
56
91
  const fs = yield* FileSystem.FileSystem
57
92
  const pathService = yield* Path.Path
@@ -239,6 +274,252 @@ const run = Effect.fnUntraced(
239
274
  Effect.provide(Prd.layer, { local: true }),
240
275
  )
241
276
 
277
+ const runWithClanka = Effect.fnUntraced(
278
+ function* (options: {
279
+ readonly startedDeferred: Deferred.Deferred<void>
280
+ readonly targetBranch: Option.Option<string>
281
+ readonly specsDirectory: string
282
+ readonly stallTimeout: Duration.Duration
283
+ readonly runTimeout: Duration.Duration
284
+ readonly review: boolean
285
+ readonly clankaModel: ClankaModel
286
+ }): Effect.fn.Return<
287
+ void,
288
+ | PlatformError.PlatformError
289
+ | Schema.SchemaError
290
+ | IssueSourceError
291
+ | GitFlowError
292
+ | ChosenTaskNotFound
293
+ | RunnerStalled
294
+ | TimeoutError
295
+ | AiError.AiError,
296
+ | CurrentProjectId
297
+ | FileSystem.FileSystem
298
+ | Path.Path
299
+ | Worktree
300
+ | ChildProcessSpawner.ChildProcessSpawner
301
+ | GithubCli
302
+ | IssueSource
303
+ | AtomRegistry.AtomRegistry
304
+ | GitFlow
305
+ | CurrentWorkerState
306
+ | PromptGen
307
+ | ClankaModels
308
+ | Scope.Scope
309
+ | Prd
310
+ > {
311
+ const projectId = yield* CurrentProjectId
312
+ const fs = yield* FileSystem.FileSystem
313
+ const pathService = yield* Path.Path
314
+ const worktree = yield* Worktree
315
+ const gh = yield* GithubCli
316
+ const source = yield* IssueSource
317
+ const gitFlow = yield* GitFlow
318
+ const currentWorker = yield* CurrentWorkerState
319
+ const registry = yield* AtomRegistry.AtomRegistry
320
+ const promptGen = yield* PromptGen
321
+ const models = yield* ClankaModels
322
+ const model = models.get(options.clankaModel)
323
+
324
+ // ensure cleanup of branch after run
325
+ yield* Effect.addFinalizer(
326
+ Effect.fnUntraced(function* () {
327
+ const currentBranchName = yield* worktree
328
+ .currentBranch(worktree.directory)
329
+ .pipe(Effect.option, Effect.map(Option.getOrUndefined))
330
+ if (!currentBranchName) return
331
+
332
+ // enter detached state
333
+ yield* worktree.exec`git checkout --detach ${currentBranchName}`
334
+ // delete the branch
335
+ yield* worktree.exec`git branch -D ${currentBranchName}`
336
+ }, Effect.ignore()),
337
+ )
338
+
339
+ let taskId: string | undefined = undefined
340
+
341
+ // setup finalizer to revert issue if we fail
342
+ yield* Effect.addFinalizer(
343
+ Effect.fnUntraced(function* (exit) {
344
+ if (exit._tag === "Success") return
345
+ if (taskId) {
346
+ yield* source.updateIssue({
347
+ projectId,
348
+ issueId: taskId,
349
+ state: "todo",
350
+ })
351
+ }
352
+ }, Effect.ignore()),
353
+ )
354
+
355
+ const taskById = (taskId: string) =>
356
+ AtomRegistry.getResult(registry, currentIssuesAtom(projectId)).pipe(
357
+ Effect.map((issues) => issues.find((entry) => entry.id === taskId)),
358
+ )
359
+
360
+ // 1. Choose task
361
+ // --------------
362
+
363
+ registry.update(currentWorker.state, (s) =>
364
+ s.transitionTo(WorkerStatus.ChoosingTask()),
365
+ )
366
+
367
+ const deferred = ChosenTaskDeferred.of(Deferred.makeUnsafe())
368
+ const chooseResult = yield* runClanka({
369
+ directory: worktree.directory,
370
+ prompt: promptGen.promptChooseClanka({ gitFlow }),
371
+ stallTimeout: options.stallTimeout,
372
+ withChoose: true,
373
+ }).pipe(
374
+ Effect.andThen(Effect.fail(new ChosenTaskNotFound())),
375
+ Effect.provideService(ChosenTaskDeferred, deferred),
376
+ Effect.provide(model),
377
+ Effect.raceFirst(Deferred.await(deferred)),
378
+ Effect.withSpan("Main.choose"),
379
+ )
380
+
381
+ const chosenTask = yield* taskById(chooseResult.taskId)
382
+ if (!chosenTask) {
383
+ return yield* new ChosenTaskNotFound()
384
+ }
385
+ taskId = chooseResult.taskId
386
+ yield* source.updateIssue({
387
+ projectId,
388
+ issueId: taskId,
389
+ state: "in-progress",
390
+ })
391
+
392
+ yield* source.ensureInProgress(projectId, taskId).pipe(
393
+ Effect.timeoutOrElse({
394
+ duration: "1 minute",
395
+ onTimeout: () => Effect.fail(new RunnerStalled()),
396
+ }),
397
+ )
398
+
399
+ yield* Deferred.completeWith(options.startedDeferred, Effect.void)
400
+
401
+ if (gitFlow.requiresGithubPr && chooseResult.githubPrNumber) {
402
+ yield* worktree.exec`gh pr checkout ${chooseResult.githubPrNumber}`
403
+ const feedback = yield* gh.prFeedbackMd(chooseResult.githubPrNumber)
404
+ yield* fs.writeFileString(
405
+ pathService.join(worktree.directory, ".lalph", "feedback.md"),
406
+ feedback,
407
+ )
408
+ }
409
+
410
+ const catchStallInReview = <A, E, R>(
411
+ effect: Effect.Effect<A, E | RunnerStalled, R>,
412
+ ) =>
413
+ Effect.catchIf(
414
+ effect,
415
+ (u): u is RunnerStalled => u instanceof RunnerStalled,
416
+ Effect.fnUntraced(function* (e) {
417
+ const issues = yield* AtomRegistry.getResult(
418
+ registry,
419
+ currentIssuesAtom(projectId),
420
+ )
421
+ const task = issues.find((entry) => entry.id === taskId)
422
+ const inReview = task?.state === "in-review"
423
+ if (inReview) return
424
+ return yield* e
425
+ }),
426
+ )
427
+
428
+ const cancelled = yield* Effect.gen(function* () {
429
+ //
430
+ // 2. Work on task
431
+ // -----------------------
432
+
433
+ registry.update(currentWorker.state, (s) =>
434
+ s.transitionTo(WorkerStatus.Working({ issueId: taskId })),
435
+ )
436
+
437
+ const instructions = promptGen.promptClanka({
438
+ specsDirectory: options.specsDirectory,
439
+ targetBranch: Option.getOrUndefined(options.targetBranch),
440
+ task: chosenTask,
441
+ githubPrNumber: chooseResult.githubPrNumber ?? undefined,
442
+ gitFlow,
443
+ })
444
+
445
+ yield* runClanka({
446
+ directory: worktree.directory,
447
+ system: promptGen.systemClanka(options),
448
+ prompt: instructions,
449
+ stallTimeout: options.stallTimeout,
450
+ }).pipe(Effect.provide(model), Effect.withSpan("Main.worker"))
451
+
452
+ // 3. Review task
453
+ // -----------------------
454
+
455
+ if (options.review) {
456
+ registry.update(currentWorker.state, (s) =>
457
+ s.transitionTo(WorkerStatus.Reviewing({ issueId: taskId })),
458
+ )
459
+
460
+ yield* runClanka({
461
+ directory: worktree.directory,
462
+ system: promptGen.systemClanka(options),
463
+ prompt: promptGen.promptReview({
464
+ prompt: instructions,
465
+ gitFlow,
466
+ }),
467
+ }).pipe(
468
+ Effect.provide(model),
469
+ catchStallInReview,
470
+ Effect.withSpan("Main.review"),
471
+ )
472
+ }
473
+ }).pipe(
474
+ Effect.timeout(options.runTimeout),
475
+ Effect.tapErrorTag("TimeoutError", () =>
476
+ runClanka({
477
+ directory: worktree.directory,
478
+ system: promptGen.systemClanka(options),
479
+ prompt: promptGen.promptTimeoutClanka({
480
+ taskId,
481
+ specsDirectory: options.specsDirectory,
482
+ }),
483
+ stallTimeout: options.stallTimeout,
484
+ }).pipe(Effect.provide(model), Effect.withSpan("Main.timeout")),
485
+ ),
486
+ Effect.raceFirst(watchTaskState({ issueId: taskId })),
487
+ Effect.as(false),
488
+ Effect.catchTag("TaskStateChanged", (error) =>
489
+ Effect.log(
490
+ `Task ${error.issueId} moved to ${error.state}; cancelling run.`,
491
+ ).pipe(Effect.as(true)),
492
+ ),
493
+ )
494
+
495
+ if (cancelled) return
496
+
497
+ yield* gitFlow.postWork({
498
+ worktree,
499
+ targetBranch: Option.getOrUndefined(options.targetBranch),
500
+ issueId: taskId,
501
+ })
502
+
503
+ const task = yield* taskById(taskId)
504
+ if (task?.autoMerge) {
505
+ yield* gitFlow.autoMerge({
506
+ targetBranch: Option.getOrUndefined(options.targetBranch),
507
+ issueId: taskId,
508
+ worktree,
509
+ })
510
+ }
511
+ },
512
+ Effect.scoped,
513
+ Effect.provide(Prd.layer, { local: true }),
514
+ )
515
+
516
+ type RunEffects = ReturnType<typeof run> | ReturnType<typeof runWithClanka>
517
+ type RunEffect = Effect.Effect<
518
+ void,
519
+ Effect.Error<RunEffects>,
520
+ Effect.Services<RunEffects>
521
+ >
522
+
242
523
  const runProject = Effect.fnUntraced(
243
524
  function* (options: {
244
525
  readonly iterations: number
@@ -246,6 +527,7 @@ const runProject = Effect.fnUntraced(
246
527
  readonly specsDirectory: string
247
528
  readonly stallTimeout: Duration.Duration
248
529
  readonly runTimeout: Duration.Duration
530
+ readonly clankaModel: ClankaModel | undefined
249
531
  }) {
250
532
  const isFinite = Number.isFinite(options.iterations)
251
533
  const iterationsDisplay = isFinite ? options.iterations : "unlimited"
@@ -276,14 +558,26 @@ const runProject = Effect.fnUntraced(
276
558
 
277
559
  yield* checkForWork.pipe(
278
560
  Effect.andThen(
279
- run({
280
- startedDeferred,
281
- targetBranch: options.project.targetBranch,
282
- specsDirectory: options.specsDirectory,
283
- stallTimeout: options.stallTimeout,
284
- runTimeout: options.runTimeout,
285
- review: options.project.reviewAgent,
286
- }).pipe(
561
+ identity<RunEffect>(
562
+ options.clankaModel
563
+ ? runWithClanka({
564
+ startedDeferred,
565
+ targetBranch: options.project.targetBranch,
566
+ specsDirectory: options.specsDirectory,
567
+ stallTimeout: options.stallTimeout,
568
+ runTimeout: options.runTimeout,
569
+ review: options.project.reviewAgent,
570
+ clankaModel: options.clankaModel,
571
+ })
572
+ : run({
573
+ startedDeferred,
574
+ targetBranch: options.project.targetBranch,
575
+ specsDirectory: options.specsDirectory,
576
+ stallTimeout: options.stallTimeout,
577
+ runTimeout: options.runTimeout,
578
+ review: options.project.reviewAgent,
579
+ }),
580
+ ).pipe(
287
581
  Effect.provide(
288
582
  options.project.gitFlow === "commit" ? GitFlowCommit : GitFlowPR,
289
583
  { local: true },
@@ -291,6 +585,7 @@ const runProject = Effect.fnUntraced(
291
585
  withWorkerState(options.project.id),
292
586
  ),
293
587
  ),
588
+ (_) => _,
294
589
  Effect.catchTags({
295
590
  NoMoreWork(_error) {
296
591
  if (isFinite) {
@@ -397,7 +692,7 @@ export const commandRoot = Command.make("lalph", {
397
692
  stallMinutes,
398
693
  specsDirectory,
399
694
  }) {
400
- yield* getDefaultCliAgentPreset
695
+ const preset = yield* getDefaultCliAgentPreset
401
696
 
402
697
  let allProjects = yield* getAllProjects
403
698
  if (allProjects.length === 0) {
@@ -420,12 +715,14 @@ export const commandRoot = Command.make("lalph", {
420
715
  specsDirectory,
421
716
  stallTimeout: Duration.minutes(stallMinutes),
422
717
  runTimeout: Duration.minutes(maxIterationMinutes),
718
+ clankaModel: preset.clankaModel,
423
719
  }).pipe(Effect.provideService(CurrentProjectId, project.id)),
424
720
  { concurrency: "unbounded", discard: true },
425
721
  )
426
722
  },
427
723
  Effect.scoped,
428
724
  Effect.provide([
725
+ ClankaModels.layer,
429
726
  PromptGen.layer,
430
727
  GithubCli.layer,
431
728
  Settings.layer,
@@ -1,6 +1,7 @@
1
1
  import { Array, Effect, identity, Option, Schema } from "effect"
2
2
  import { CliAgentFromId } from "./CliAgent.ts"
3
3
  import { ChildProcess } from "effect/unstable/process"
4
+ import { ClankaModel } from "../ClankaModels.ts"
4
5
 
5
6
  export const CliAgentPresetId = Schema.NonEmptyString.pipe(
6
7
  Schema.brand("lalph/CliAgentPresetId"),
@@ -15,6 +16,7 @@ export class CliAgentPreset extends Schema.Class<CliAgentPreset>(
15
16
  commandPrefix: Schema.Array(Schema.String),
16
17
  extraArgs: Schema.Array(Schema.String),
17
18
  sourceMetadata: Schema.Record(Schema.String, Schema.Any),
19
+ clankaModel: Schema.optionalKey(ClankaModel),
18
20
  }) {
19
21
  static defaultId = CliAgentPresetId.makeUnsafe("default")
20
22
 
@@ -7,6 +7,7 @@ export class PrdIssue extends Schema.Class<PrdIssue>("PrdIssue")({
7
7
  .annotate({
8
8
  description:
9
9
  "The unique identifier of the issue. If null, it is considered a new issue.",
10
+ documentation: "The unique identifier of the issue.",
10
11
  })
11
12
  .pipe(withEncodeDefault(() => null)),
12
13
  title: Schema.String.annotate({
@@ -18,11 +19,15 @@ export class PrdIssue extends Schema.Class<PrdIssue>("PrdIssue")({
18
19
  priority: Schema.Finite.annotate({
19
20
  description:
20
21
  "The priority of the issue. 0 = No priority, 1 = Urgent, 2 = High, 3 = Normal, 4 = Low.",
22
+ documentation:
23
+ "The priority of the issue. 0 = No priority, 1 = Urgent, 2 = High, 3 = Normal, 4 = Low.",
21
24
  }).pipe(withEncodeDefault(() => 3)),
22
25
  estimate: Schema.NullOr(Schema.Finite)
23
26
  .annotate({
24
27
  description:
25
28
  "The estimate of the issue in points. Null if no estimate is set. Roughly 1 point = 1 hour of work.",
29
+ documentation:
30
+ "Null if no estimate is set. Roughly 1 point = 1 hour of work.",
26
31
  })
27
32
  .pipe(withEncodeDefault(() => null)),
28
33
  state: Schema.Literals([
@@ -34,12 +39,14 @@ export class PrdIssue extends Schema.Class<PrdIssue>("PrdIssue")({
34
39
  ])
35
40
  .annotate({
36
41
  description: "The state of the issue.",
42
+ documentation: "The state of the issue.",
37
43
  })
38
44
  .pipe(withEncodeDefault(() => "todo")),
39
45
  blockedBy: Schema.Array(Schema.String)
40
46
  .annotate({
41
47
  description:
42
48
  "An array of issue IDs that block this issue. These issues must be completed before this issue can be worked on.",
49
+ documentation: "An array of issue IDs that block this issue.",
43
50
  })
44
51
  .pipe(withEncodeDefault(() => [])),
45
52
  autoMerge: Schema.Boolean.annotate({
@@ -1,4 +1,5 @@
1
- import { Effect, flow, Schema, Stream } from "effect"
1
+ import { DateTime, Duration, Effect, flow, Schema, Stream } from "effect"
2
+ import { RunnerStalled } from "../domain/Errors.ts"
2
3
 
3
4
  export const streamFilterJson = <S extends Schema.Top>(schema: S) => {
4
5
  const fromString = Schema.fromJsonString(schema)
@@ -8,3 +9,30 @@ export const streamFilterJson = <S extends Schema.Top>(schema: S) => {
8
9
  Stream.filterMapEffect((line) => decode(line).pipe(Effect.result)),
9
10
  )
10
11
  }
12
+
13
+ export const withStallTimeout = (timeout: Duration.Input) => {
14
+ const duration = Duration.fromInputUnsafe(timeout)
15
+ return <A, E, R>(stream: Stream.Stream<A, E, R>) =>
16
+ Stream.suspend(() => {
17
+ let lastOutputAt = DateTime.nowUnsafe()
18
+ const stallTimeout = Effect.suspend(function loop(): Effect.Effect<
19
+ never,
20
+ RunnerStalled
21
+ > {
22
+ const now = DateTime.nowUnsafe()
23
+ const deadline = DateTime.addDuration(lastOutputAt, duration)
24
+ if (DateTime.isLessThan(deadline, now)) {
25
+ return Effect.fail(new RunnerStalled())
26
+ }
27
+ const timeUntilDeadline = DateTime.distance(deadline, now)
28
+ return Effect.flatMap(Effect.sleep(timeUntilDeadline), loop)
29
+ })
30
+ return stream.pipe(
31
+ Stream.tap(() => {
32
+ lastOutputAt = DateTime.nowUnsafe()
33
+ return Effect.void
34
+ }),
35
+ Stream.mergeLeft(Stream.fromEffectDrain(stallTimeout)),
36
+ )
37
+ })
38
+ }