@kynetic-ai/spec 0.11.0 → 0.12.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.
- package/README.md +55 -455
- package/dist/agent-runtime/bootstrap.d.ts +31 -0
- package/dist/agent-runtime/bootstrap.d.ts.map +1 -0
- package/dist/agent-runtime/bootstrap.js +302 -0
- package/dist/agent-runtime/bootstrap.js.map +1 -0
- package/dist/agent-runtime/dispatch.d.ts +119 -10
- package/dist/agent-runtime/dispatch.d.ts.map +1 -1
- package/dist/agent-runtime/dispatch.js +1154 -219
- package/dist/agent-runtime/dispatch.js.map +1 -1
- package/dist/agent-runtime/invocation.d.ts +28 -1
- package/dist/agent-runtime/invocation.d.ts.map +1 -1
- package/dist/agent-runtime/invocation.js +171 -59
- package/dist/agent-runtime/invocation.js.map +1 -1
- package/dist/agent-runtime/prompts.d.ts +9 -0
- package/dist/agent-runtime/prompts.d.ts.map +1 -1
- package/dist/agent-runtime/prompts.js +42 -7
- package/dist/agent-runtime/prompts.js.map +1 -1
- package/dist/agent-runtime/session-event-accumulator.d.ts +83 -0
- package/dist/agent-runtime/session-event-accumulator.d.ts.map +1 -0
- package/dist/agent-runtime/session-event-accumulator.js +203 -0
- package/dist/agent-runtime/session-event-accumulator.js.map +1 -0
- package/dist/agent-runtime/session-event-types.d.ts +67 -0
- package/dist/agent-runtime/session-event-types.d.ts.map +1 -0
- package/dist/agent-runtime/session-event-types.js +13 -0
- package/dist/agent-runtime/session-event-types.js.map +1 -0
- package/dist/agent-runtime/workspace.d.ts +244 -0
- package/dist/agent-runtime/workspace.d.ts.map +1 -0
- package/dist/agent-runtime/workspace.js +2025 -0
- package/dist/agent-runtime/workspace.js.map +1 -0
- package/dist/agents/adapters.d.ts.map +1 -1
- package/dist/agents/adapters.js +58 -13
- package/dist/agents/adapters.js.map +1 -1
- package/dist/agents/spawner.d.ts +8 -0
- package/dist/agents/spawner.d.ts.map +1 -1
- package/dist/agents/spawner.js +25 -3
- package/dist/agents/spawner.js.map +1 -1
- package/dist/cli/batch-exec.js +1 -1
- package/dist/cli/batch-exec.js.map +1 -1
- package/dist/cli/command-annotations.d.ts +15 -3
- package/dist/cli/command-annotations.d.ts.map +1 -1
- package/dist/cli/command-annotations.js +23 -3
- package/dist/cli/command-annotations.js.map +1 -1
- package/dist/cli/commands/agent.d.ts +2 -0
- package/dist/cli/commands/agent.d.ts.map +1 -1
- package/dist/cli/commands/agent.js +144 -27
- package/dist/cli/commands/agent.js.map +1 -1
- package/dist/cli/commands/agents.d.ts.map +1 -1
- package/dist/cli/commands/agents.js +5 -5
- package/dist/cli/commands/agents.js.map +1 -1
- package/dist/cli/commands/derive.d.ts.map +1 -1
- package/dist/cli/commands/derive.js +118 -3
- package/dist/cli/commands/derive.js.map +1 -1
- package/dist/cli/commands/guard.d.ts.map +1 -1
- package/dist/cli/commands/guard.js +8 -6
- package/dist/cli/commands/guard.js.map +1 -1
- package/dist/cli/commands/index.d.ts +1 -0
- package/dist/cli/commands/index.d.ts.map +1 -1
- package/dist/cli/commands/index.js +1 -0
- package/dist/cli/commands/index.js.map +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +20 -0
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/item.d.ts.map +1 -1
- package/dist/cli/commands/item.js +205 -47
- package/dist/cli/commands/item.js.map +1 -1
- package/dist/cli/commands/log.d.ts.map +1 -1
- package/dist/cli/commands/log.js +24 -10
- package/dist/cli/commands/log.js.map +1 -1
- package/dist/cli/commands/plan-import.d.ts +3 -3
- package/dist/cli/commands/plan-import.d.ts.map +1 -1
- package/dist/cli/commands/plan-import.js +213 -528
- package/dist/cli/commands/plan-import.js.map +1 -1
- package/dist/cli/commands/plan.d.ts.map +1 -1
- package/dist/cli/commands/plan.js +533 -83
- package/dist/cli/commands/plan.js.map +1 -1
- package/dist/cli/commands/review.d.ts +14 -0
- package/dist/cli/commands/review.d.ts.map +1 -0
- package/dist/cli/commands/review.js +1142 -0
- package/dist/cli/commands/review.js.map +1 -0
- package/dist/cli/commands/serve.d.ts +1 -0
- package/dist/cli/commands/serve.d.ts.map +1 -1
- package/dist/cli/commands/serve.js +33 -10
- package/dist/cli/commands/serve.js.map +1 -1
- package/dist/cli/commands/session/checkpoint.d.ts +2 -4
- package/dist/cli/commands/session/checkpoint.d.ts.map +1 -1
- package/dist/cli/commands/session/checkpoint.js +6 -107
- package/dist/cli/commands/session/checkpoint.js.map +1 -1
- package/dist/cli/commands/session/commands.d.ts.map +1 -1
- package/dist/cli/commands/session/commands.js +33 -23
- package/dist/cli/commands/session/commands.js.map +1 -1
- package/dist/cli/commands/session/compact.js +4 -4
- package/dist/cli/commands/session/compact.js.map +1 -1
- package/dist/cli/commands/session/create.js +2 -2
- package/dist/cli/commands/session/create.js.map +1 -1
- package/dist/cli/commands/session/format.d.ts.map +1 -1
- package/dist/cli/commands/session/format.js +1 -6
- package/dist/cli/commands/session/format.js.map +1 -1
- package/dist/cli/commands/session/log.d.ts +32 -7
- package/dist/cli/commands/session/log.d.ts.map +1 -1
- package/dist/cli/commands/session/log.js +166 -60
- package/dist/cli/commands/session/log.js.map +1 -1
- package/dist/cli/commands/session/migrate.d.ts +9 -0
- package/dist/cli/commands/session/migrate.d.ts.map +1 -0
- package/dist/cli/commands/session/migrate.js +46 -0
- package/dist/cli/commands/session/migrate.js.map +1 -0
- package/dist/cli/commands/session/stale-close.d.ts.map +1 -1
- package/dist/cli/commands/session/stale-close.js +5 -8
- package/dist/cli/commands/session/stale-close.js.map +1 -1
- package/dist/cli/commands/session/types.d.ts +1 -1
- package/dist/cli/commands/session/types.d.ts.map +1 -1
- package/dist/cli/commands/setup.d.ts +2 -2
- package/dist/cli/commands/setup.d.ts.map +1 -1
- package/dist/cli/commands/setup.js +287 -257
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/shadow.d.ts.map +1 -1
- package/dist/cli/commands/shadow.js +147 -31
- package/dist/cli/commands/shadow.js.map +1 -1
- package/dist/cli/commands/skill-crud.d.ts +7 -0
- package/dist/cli/commands/skill-crud.d.ts.map +1 -1
- package/dist/cli/commands/skill-crud.js +41 -18
- package/dist/cli/commands/skill-crud.js.map +1 -1
- package/dist/cli/commands/skill-diff.d.ts.map +1 -1
- package/dist/cli/commands/skill-diff.js +29 -3
- package/dist/cli/commands/skill-diff.js.map +1 -1
- package/dist/cli/commands/skill-install.d.ts.map +1 -1
- package/dist/cli/commands/skill-install.js +5 -4
- package/dist/cli/commands/skill-install.js.map +1 -1
- package/dist/cli/commands/task.d.ts.map +1 -1
- package/dist/cli/commands/task.js +359 -49
- package/dist/cli/commands/task.js.map +1 -1
- package/dist/cli/commands/trait.d.ts.map +1 -1
- package/dist/cli/commands/trait.js +5 -27
- package/dist/cli/commands/trait.js.map +1 -1
- package/dist/cli/commands/validate.d.ts.map +1 -1
- package/dist/cli/commands/validate.js +113 -52
- package/dist/cli/commands/validate.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +69 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/output.d.ts +26 -0
- package/dist/cli/output.d.ts.map +1 -1
- package/dist/cli/output.js +108 -1
- package/dist/cli/output.js.map +1 -1
- package/dist/cli/sync-mode.d.ts +44 -0
- package/dist/cli/sync-mode.d.ts.map +1 -0
- package/dist/cli/sync-mode.js +64 -0
- package/dist/cli/sync-mode.js.map +1 -0
- package/dist/daemon/middleware/project-context.ts +25 -7
- package/dist/daemon/project-context.ts +18 -0
- package/dist/daemon/routes/agent-dispatch.ts +99 -22
- package/dist/daemon/routes/aggregation.ts +184 -0
- package/dist/daemon/routes/inbox.ts +5 -0
- package/dist/daemon/routes/items.ts +145 -0
- package/dist/daemon/routes/meta.ts +1 -1
- package/dist/daemon/routes/projects.ts +28 -6
- package/dist/daemon/routes/ref-resolution.ts +119 -0
- package/dist/daemon/routes/refs.ts +42 -0
- package/dist/daemon/routes/session-related.ts +140 -0
- package/dist/daemon/routes/sessions.ts +420 -19
- package/dist/daemon/routes/tasks.ts +62 -5
- package/dist/daemon/routes/triage.ts +40 -1
- package/dist/daemon/server.ts +143 -49
- package/dist/daemon/session-sync.ts +11 -0
- package/dist/daemon/shadow-sync.ts +11 -0
- package/dist/daemon/watcher.ts +56 -5
- package/dist/daemon/websocket/project-resolution.ts +77 -0
- package/dist/export/json.d.ts.map +1 -1
- package/dist/export/json.js +104 -1
- package/dist/export/json.js.map +1 -1
- package/dist/export/types.d.ts +52 -1
- package/dist/export/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/parser/agent-detection.d.ts +1 -1
- package/dist/parser/agent-detection.d.ts.map +1 -1
- package/dist/parser/agent-detection.js +10 -0
- package/dist/parser/agent-detection.js.map +1 -1
- package/dist/parser/config.d.ts +397 -2
- package/dist/parser/config.d.ts.map +1 -1
- package/dist/parser/config.js +125 -3
- package/dist/parser/config.js.map +1 -1
- package/dist/parser/dispatch-workspaces.d.ts +18 -0
- package/dist/parser/dispatch-workspaces.d.ts.map +1 -0
- package/dist/parser/dispatch-workspaces.js +209 -0
- package/dist/parser/dispatch-workspaces.js.map +1 -0
- package/dist/parser/doctor.d.ts.map +1 -1
- package/dist/parser/doctor.js +27 -8
- package/dist/parser/doctor.js.map +1 -1
- package/dist/parser/file-lock.d.ts.map +1 -1
- package/dist/parser/file-lock.js +9 -2
- package/dist/parser/file-lock.js.map +1 -1
- package/dist/parser/index.d.ts +6 -0
- package/dist/parser/index.d.ts.map +1 -1
- package/dist/parser/index.js +6 -0
- package/dist/parser/index.js.map +1 -1
- package/dist/parser/plans.d.ts.map +1 -1
- package/dist/parser/plans.js +1 -0
- package/dist/parser/plans.js.map +1 -1
- package/dist/parser/refs.d.ts +8 -1
- package/dist/parser/refs.d.ts.map +1 -1
- package/dist/parser/refs.js +27 -1
- package/dist/parser/refs.js.map +1 -1
- package/dist/parser/review-operations.d.ts +72 -0
- package/dist/parser/review-operations.d.ts.map +1 -0
- package/dist/parser/review-operations.js +185 -0
- package/dist/parser/review-operations.js.map +1 -0
- package/dist/parser/review-task-integration.d.ts +78 -0
- package/dist/parser/review-task-integration.d.ts.map +1 -0
- package/dist/parser/review-task-integration.js +173 -0
- package/dist/parser/review-task-integration.js.map +1 -0
- package/dist/parser/review-threads.d.ts +101 -0
- package/dist/parser/review-threads.d.ts.map +1 -0
- package/dist/parser/review-threads.js +222 -0
- package/dist/parser/review-threads.js.map +1 -0
- package/dist/parser/review-validation.d.ts +69 -0
- package/dist/parser/review-validation.d.ts.map +1 -0
- package/dist/parser/review-validation.js +207 -0
- package/dist/parser/review-validation.js.map +1 -0
- package/dist/parser/reviews.d.ts +58 -0
- package/dist/parser/reviews.d.ts.map +1 -0
- package/dist/parser/reviews.js +230 -0
- package/dist/parser/reviews.js.map +1 -0
- package/dist/parser/session-branch.d.ts +91 -0
- package/dist/parser/session-branch.d.ts.map +1 -0
- package/dist/parser/session-branch.js +565 -0
- package/dist/parser/session-branch.js.map +1 -0
- package/dist/parser/session-sync-scheduler.d.ts +53 -0
- package/dist/parser/session-sync-scheduler.d.ts.map +1 -0
- package/dist/parser/session-sync-scheduler.js +100 -0
- package/dist/parser/session-sync-scheduler.js.map +1 -0
- package/dist/parser/setup-status.d.ts +7 -1
- package/dist/parser/setup-status.d.ts.map +1 -1
- package/dist/parser/setup-status.js +104 -39
- package/dist/parser/setup-status.js.map +1 -1
- package/dist/parser/shadow-sync-scheduler.d.ts +71 -0
- package/dist/parser/shadow-sync-scheduler.d.ts.map +1 -0
- package/dist/parser/shadow-sync-scheduler.js +139 -0
- package/dist/parser/shadow-sync-scheduler.js.map +1 -0
- package/dist/parser/shadow.d.ts +121 -14
- package/dist/parser/shadow.d.ts.map +1 -1
- package/dist/parser/shadow.js +752 -27
- package/dist/parser/shadow.js.map +1 -1
- package/dist/parser/skill-render.d.ts +24 -0
- package/dist/parser/skill-render.d.ts.map +1 -1
- package/dist/parser/skill-render.js +98 -26
- package/dist/parser/skill-render.js.map +1 -1
- package/dist/parser/validate.d.ts +43 -3
- package/dist/parser/validate.d.ts.map +1 -1
- package/dist/parser/validate.js +204 -30
- package/dist/parser/validate.js.map +1 -1
- package/dist/parser/yaml.d.ts +47 -11
- package/dist/parser/yaml.d.ts.map +1 -1
- package/dist/parser/yaml.js +329 -149
- package/dist/parser/yaml.js.map +1 -1
- package/dist/review/checks.d.ts +97 -0
- package/dist/review/checks.d.ts.map +1 -0
- package/dist/review/checks.js +175 -0
- package/dist/review/checks.js.map +1 -0
- package/dist/review/index.d.ts +3 -0
- package/dist/review/index.d.ts.map +1 -0
- package/dist/review/index.js +3 -0
- package/dist/review/index.js.map +1 -0
- package/dist/review/subject-bindings.d.ts +83 -0
- package/dist/review/subject-bindings.d.ts.map +1 -0
- package/dist/review/subject-bindings.js +175 -0
- package/dist/review/subject-bindings.js.map +1 -0
- package/dist/schema/common.d.ts +26 -0
- package/dist/schema/common.d.ts.map +1 -1
- package/dist/schema/common.js +13 -0
- package/dist/schema/common.js.map +1 -1
- package/dist/schema/dispatch-workspace.d.ts +2643 -0
- package/dist/schema/dispatch-workspace.d.ts.map +1 -0
- package/dist/schema/dispatch-workspace.js +187 -0
- package/dist/schema/dispatch-workspace.js.map +1 -0
- package/dist/schema/inbox.d.ts +8 -8
- package/dist/schema/index.d.ts +2 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +2 -0
- package/dist/schema/index.js.map +1 -1
- package/dist/schema/meta.d.ts +648 -116
- package/dist/schema/meta.d.ts.map +1 -1
- package/dist/schema/meta.js +27 -0
- package/dist/schema/meta.js.map +1 -1
- package/dist/schema/plan.d.ts +30 -19
- package/dist/schema/plan.d.ts.map +1 -1
- package/dist/schema/plan.js +3 -1
- package/dist/schema/plan.js.map +1 -1
- package/dist/schema/review-records.d.ts +2676 -0
- package/dist/schema/review-records.d.ts.map +1 -0
- package/dist/schema/review-records.js +232 -0
- package/dist/schema/review-records.js.map +1 -0
- package/dist/schema/spec.d.ts +32 -14
- package/dist/schema/spec.d.ts.map +1 -1
- package/dist/schema/spec.js +5 -0
- package/dist/schema/spec.js.map +1 -1
- package/dist/schema/task.d.ts +187 -29
- package/dist/schema/task.d.ts.map +1 -1
- package/dist/schema/task.js +12 -2
- package/dist/schema/task.js.map +1 -1
- package/dist/schema/triage.d.ts +22 -22
- package/dist/sessions/cache.d.ts +119 -0
- package/dist/sessions/cache.d.ts.map +1 -0
- package/dist/sessions/cache.js +284 -0
- package/dist/sessions/cache.js.map +1 -0
- package/dist/sessions/index.d.ts +1 -0
- package/dist/sessions/index.d.ts.map +1 -1
- package/dist/sessions/index.js +2 -0
- package/dist/sessions/index.js.map +1 -1
- package/dist/sessions/legacy.d.ts +77 -0
- package/dist/sessions/legacy.d.ts.map +1 -0
- package/dist/sessions/legacy.js +146 -0
- package/dist/sessions/legacy.js.map +1 -0
- package/dist/sessions/store.d.ts +103 -73
- package/dist/sessions/store.d.ts.map +1 -1
- package/dist/sessions/store.js +335 -186
- package/dist/sessions/store.js.map +1 -1
- package/dist/sessions/types.d.ts +44 -16
- package/dist/sessions/types.d.ts.map +1 -1
- package/dist/sessions/types.js +11 -2
- package/dist/sessions/types.js.map +1 -1
- package/dist/strings/errors.d.ts +32 -0
- package/dist/strings/errors.d.ts.map +1 -1
- package/dist/strings/errors.js +17 -0
- package/dist/strings/errors.js.map +1 -1
- package/dist/strings/labels.d.ts +1 -0
- package/dist/strings/labels.d.ts.map +1 -1
- package/dist/strings/labels.js +1 -0
- package/dist/strings/labels.js.map +1 -1
- package/dist/utils/activity.d.ts +101 -0
- package/dist/utils/activity.d.ts.map +1 -0
- package/dist/utils/activity.js +408 -0
- package/dist/utils/activity.js.map +1 -0
- package/dist/utils/git.d.ts +31 -0
- package/dist/utils/git.d.ts.map +1 -1
- package/dist/utils/git.js +87 -0
- package/dist/utils/git.js.map +1 -1
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/web-ui/_app/immutable/assets/0.tmlwn-Ih.css +1 -0
- package/dist/web-ui/_app/immutable/assets/9.BwwJybWx.css +1 -0
- package/dist/web-ui/_app/immutable/chunks/2KqE8gtn.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/70-t_QvE.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/AiWQj974.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/{CPPfDSei.js → B25nWFyA.js} +4 -4
- package/dist/web-ui/_app/immutable/chunks/{DBYE9jOd.js → B2bcA_Q_.js} +1 -1
- package/dist/web-ui/_app/immutable/chunks/B5e5HYyB.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/B7-5z6eA.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/B7bGmhK0.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/{DzO4hlg9.js → B8tYZKAE.js} +1 -1
- package/dist/web-ui/_app/immutable/chunks/{B5LJFxqa.js → BFGAyJjD.js} +1 -1
- package/dist/web-ui/_app/immutable/chunks/BG0850zf.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/{DAMmvwn4.js → BG8eSzAd.js} +1 -1
- package/dist/web-ui/_app/immutable/chunks/BIMxXS8I.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/BSzL1fpU.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/BYtjHfeq.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/{DxCk-KHc.js → Bp5pFYXL.js} +1 -1
- package/dist/web-ui/_app/immutable/chunks/{B8a0xDxR.js → BsJFsuAT.js} +1 -1
- package/dist/web-ui/_app/immutable/chunks/BvpNHcD6.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/BypqA25-.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/{BVA9Exy-.js → C0w6WDm5.js} +1 -1
- package/dist/web-ui/_app/immutable/chunks/C5_PAZ0y.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/CDRO15Iv.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/CF1CoqD5.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/CS2sa4_m.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/{BJ0JX3ea.js → CWUQwB9H.js} +1 -1
- package/dist/web-ui/_app/immutable/chunks/CY5FDdSU.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/C_7MTDoj.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/{D3vxvonu.js → CaAJD3dl.js} +1 -1
- package/dist/web-ui/_app/immutable/chunks/{BP352uRn.js → ChB5iyEL.js} +1 -1
- package/dist/web-ui/_app/immutable/chunks/{pE6cYWlS.js → ChQD-6N8.js} +1 -1
- package/dist/web-ui/_app/immutable/chunks/{Eo4gF7ih.js → CqbsoCwA.js} +1 -1
- package/dist/web-ui/_app/immutable/chunks/DCeJW50p.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/{Cncwi6fQ.js → DJtZNgcs.js} +1 -1
- package/dist/web-ui/_app/immutable/chunks/DKIeaprD.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/DLd2uVIA.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/{DjcCz-PU.js → DW_subyT.js} +2 -2
- package/dist/web-ui/_app/immutable/chunks/DbU6lVn0.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/Dc7ZCC5m.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/Dd5umPsk.js +2 -0
- package/dist/web-ui/_app/immutable/chunks/{BysXJlZb.js → Dg_zDpDS.js} +1 -1
- package/dist/web-ui/_app/immutable/chunks/Dgqu8Yuc.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/DmxsPZTB.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/DphTaFUB.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/DqK4iHp0.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/{D9QNBZM2.js → DqT6OH_u.js} +2 -2
- package/dist/web-ui/_app/immutable/chunks/Ds9I9wQb.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/Du5ng3u4.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/DxJw79Wi.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/GFTX8GgV.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/{C076q4JN.js → HNjs76Zz.js} +1 -1
- package/dist/web-ui/_app/immutable/chunks/HVMjDi4_.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/{BkOJ8DkV.js → P0A_fJvS.js} +1 -1
- package/dist/web-ui/_app/immutable/chunks/T3vGWjIL.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/VTmrX9Qu.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/{k_Qegko0.js → Xvwhx_F1.js} +1 -1
- package/dist/web-ui/_app/immutable/chunks/Yyz1XMQA.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/{62JVKtnb.js → dh5HeqUr.js} +1 -1
- package/dist/web-ui/_app/immutable/chunks/fZMteyca.js +62 -0
- package/dist/web-ui/_app/immutable/chunks/{D82RulSH.js → gPrj-hqC.js} +1 -1
- package/dist/web-ui/_app/immutable/chunks/htcWMiYN.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/{CwELQvbx.js → oTsvd9y4.js} +1 -1
- package/dist/web-ui/_app/immutable/chunks/qJfLUwU4.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/xCtiO_JE.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/{DvA-KON-.js → y4GeEH6k.js} +1 -1
- package/dist/web-ui/_app/immutable/entry/app.C4h_eOn6.js +2 -0
- package/dist/web-ui/_app/immutable/entry/start.CQFTf9ep.js +1 -0
- package/dist/web-ui/_app/immutable/nodes/0.Dh1xO970.js +1 -0
- package/dist/web-ui/_app/immutable/nodes/1.l75D3Opx.js +1 -0
- package/dist/web-ui/_app/immutable/nodes/10.DBidBPc-.js +1 -0
- package/dist/web-ui/_app/immutable/nodes/11.Ab0gUKWe.js +1 -0
- package/dist/web-ui/_app/immutable/nodes/12.CMsnoxfs.js +1 -0
- package/dist/web-ui/_app/immutable/nodes/13.D8YKuknB.js +1 -0
- package/dist/web-ui/_app/immutable/nodes/14.DZ0aan7y.js +1 -0
- package/dist/web-ui/_app/immutable/nodes/15.CUIKreDL.js +2 -0
- package/dist/web-ui/_app/immutable/nodes/16.BWc8--BO.js +1 -0
- package/dist/web-ui/_app/immutable/nodes/2.CDUonbuh.js +1 -0
- package/dist/web-ui/_app/immutable/nodes/3.Ctg3M00i.js +1 -0
- package/dist/web-ui/_app/immutable/nodes/4.Ci-JDwbA.js +2 -0
- package/dist/web-ui/_app/immutable/nodes/5.CTyEDAq0.js +1 -0
- package/dist/web-ui/_app/immutable/nodes/6.BTZZqsAb.js +1 -0
- package/dist/web-ui/_app/immutable/nodes/7.BI52g_Jo.js +137 -0
- package/dist/web-ui/_app/immutable/nodes/8.3hZPaB9x.js +1 -0
- package/dist/web-ui/_app/immutable/nodes/9.DS49kvwl.js +29 -0
- package/dist/web-ui/_app/version.json +1 -1
- package/dist/web-ui/favicon-192.png +0 -0
- package/dist/web-ui/favicon-32.png +0 -0
- package/dist/web-ui/favicon.ico +0 -0
- package/dist/web-ui/index.html +14 -14
- package/package.json +12 -6
- package/plugin/.claude-plugin/marketplace.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/kspec/skills/merge/SKILL.md +127 -0
- package/plugin/plugins/kspec/skills/plan/SKILL.md +55 -26
- package/plugin/plugins/kspec/skills/review/SKILL.md +350 -133
- package/plugin/plugins/kspec/skills/task-work/SKILL.md +96 -106
- package/templates/agents-sections/04-pr-workflow.md +15 -12
- package/templates/agents-sections/06-ralph-loop.md +15 -10
- package/templates/skills/manifest.yaml +25 -7
- package/templates/skills/merge/SKILL.md +120 -0
- package/templates/skills/plan/SKILL.md +55 -26
- package/templates/skills/review/SKILL.md +346 -130
- package/templates/skills/task-work/SKILL.md +93 -103
- package/dist/web-ui/_app/immutable/assets/0.BJaYkGW2.css +0 -1
- package/dist/web-ui/_app/immutable/assets/9.SzGLxi4x.css +0 -1
- package/dist/web-ui/_app/immutable/chunks/-lc0BifF.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/8RBjHMN1.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/B5wTVqxm.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/B6VSmczZ.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/BEOQc37C.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/BHtYorjv.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/BMuCqDX8.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/BUZujXJ2.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/BWET-efb.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/BXkNecpt.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/BYzrIfX8.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/BpuwufMc.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/BwMO4RrG.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/C33JaVbg.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/CGtqifKp.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/CHDZZ7OG.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/CUir3f4J.js +0 -60
- package/dist/web-ui/_app/immutable/chunks/CrCIbn0C.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/D6TVmR9T.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/D7LTux4W.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/DAh4Wfku.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/DAx07bEQ.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/DOno4cA2.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/DQA8NZIH.js +0 -2
- package/dist/web-ui/_app/immutable/chunks/DRfPm2bo.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/DhQhksaB.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/DjG7s6hm.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/DkltRNvh.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/DlaTnPKL.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/ExCq5swK.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/T3zZGv51.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/XZumBYeP.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/_ySfNjkF.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/iEtR5cV6.js +0 -1
- package/dist/web-ui/_app/immutable/entry/app.Cgu6uKeS.js +0 -2
- package/dist/web-ui/_app/immutable/entry/start.9XifnLoB.js +0 -1
- package/dist/web-ui/_app/immutable/nodes/0.DISwcKSK.js +0 -1
- package/dist/web-ui/_app/immutable/nodes/1.Cx2Ufqp1.js +0 -1
- package/dist/web-ui/_app/immutable/nodes/10.C3z8ijXL.js +0 -1
- package/dist/web-ui/_app/immutable/nodes/11.DZdIjZmM.js +0 -1
- package/dist/web-ui/_app/immutable/nodes/12.FsIGfAOa.js +0 -1
- package/dist/web-ui/_app/immutable/nodes/13.DZoFwagf.js +0 -1
- package/dist/web-ui/_app/immutable/nodes/14.DaIzDKbQ.js +0 -1
- package/dist/web-ui/_app/immutable/nodes/15.BYyt4XWF.js +0 -2
- package/dist/web-ui/_app/immutable/nodes/16.CQkSqpOe.js +0 -1
- package/dist/web-ui/_app/immutable/nodes/2.Bkf_j2UJ.js +0 -1
- package/dist/web-ui/_app/immutable/nodes/3.kaMCurJG.js +0 -1
- package/dist/web-ui/_app/immutable/nodes/4.BSsFPTHG.js +0 -2
- package/dist/web-ui/_app/immutable/nodes/5.CpPlcCEZ.js +0 -1
- package/dist/web-ui/_app/immutable/nodes/6.BN4FqQmY.js +0 -1
- package/dist/web-ui/_app/immutable/nodes/7.9kBYIZik.js +0 -1
- package/dist/web-ui/_app/immutable/nodes/8.BuijtZ6B.js +0 -1
- package/dist/web-ui/_app/immutable/nodes/9.C-Weba8R.js +0 -1
|
@@ -12,9 +12,15 @@
|
|
|
12
12
|
import * as path from "node:path";
|
|
13
13
|
import { spawnSync } from "node:child_process";
|
|
14
14
|
import { ulid } from "ulid";
|
|
15
|
-
import { initContext, loadAllTasks, loadMetaContext, } from "../parser/index.js";
|
|
16
|
-
import { runInvocation } from "./invocation.js";
|
|
15
|
+
import { initContext, loadAllTasks, loadMetaContext, areDependenciesMet, loadReviewRecords, } from "../parser/index.js";
|
|
16
|
+
import { DEFAULT_KSPEC_CLI_PATH, runInvocation } from "./invocation.js";
|
|
17
|
+
import { loadProjectConfig } from "../parser/config.js";
|
|
18
|
+
import { SessionEventAccumulator } from "./session-event-accumulator.js";
|
|
19
|
+
import { interpolateTemplate, rewriteSkillReferencesForAdapter, } from "./prompts.js";
|
|
17
20
|
import { getAdapter } from "../agents/adapters.js";
|
|
21
|
+
import { provisionDispatchWorkspace, DispatchWorkspaceError, getDispatchShadowMutationLockPath, markDispatchWorkspaceActive, markDispatchWorkspaceIdle, reconcileDispatchWorkspaceRegistry, getDispatchWorkspaceHealth, reconcileDispatchWorkspaceLifecycle, cleanupReviewerDispatchWorkspace, reconcileDispatchWorkspaceArtifacts, discoverWorkspaceForReviewOrFixCycle, } from "./workspace.js";
|
|
22
|
+
import { ensureWorkspaceBootstrap, DispatchBootstrapError, } from "./bootstrap.js";
|
|
23
|
+
import { getSessionCache } from "../sessions/cache.js";
|
|
18
24
|
// ─── Simple Mutex ─────────────────────────────────────────────────────────────
|
|
19
25
|
/**
|
|
20
26
|
* A minimal promise-based mutex for serializing async operations.
|
|
@@ -79,22 +85,16 @@ const STATUS_TO_EVENT = {
|
|
|
79
85
|
const STATUS_PRECEDENCE = {
|
|
80
86
|
in_progress: 0,
|
|
81
87
|
needs_work: 1,
|
|
82
|
-
|
|
83
|
-
|
|
88
|
+
pending_review: 2,
|
|
89
|
+
pending: 3,
|
|
84
90
|
blocked: 4,
|
|
85
91
|
completed: 5,
|
|
86
92
|
cancelled: 6,
|
|
87
93
|
};
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
*
|
|
93
|
-
* AC: @agent-dispatch-engine ac-16
|
|
94
|
-
*/
|
|
95
|
-
export function interpolateTemplate(template, vars) {
|
|
96
|
-
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => vars[key] ?? match);
|
|
97
|
-
}
|
|
94
|
+
const CONTINUITY_STARVATION_THRESHOLD = 2;
|
|
95
|
+
// ─── Prompt Helpers ──────────────────────────────────────────────────────────
|
|
96
|
+
// AC: @agent-dispatch-engine ac-16 — re-exported from prompts.ts for backwards compat
|
|
97
|
+
export { interpolateTemplate };
|
|
98
98
|
/**
|
|
99
99
|
* Human-readable trigger description for orientation context.
|
|
100
100
|
*/
|
|
@@ -112,6 +112,21 @@ function triggerDescription(trigger) {
|
|
|
112
112
|
return `Trigger: ${trigger}`;
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
|
+
function focusDescription(trigger, role) {
|
|
116
|
+
if (role === "reviewer") {
|
|
117
|
+
return "Review the submitted changes in this snapshot and decide whether the task should advance or return for fixes.";
|
|
118
|
+
}
|
|
119
|
+
if (trigger === "task.needs_work") {
|
|
120
|
+
return "Resume the canonical worker branch, address review findings, and move the task back toward review.";
|
|
121
|
+
}
|
|
122
|
+
if (trigger === "task.in_progress") {
|
|
123
|
+
return "Resume the existing canonical worker branch and continue the in-flight implementation.";
|
|
124
|
+
}
|
|
125
|
+
return "Work the assigned task in this mutable workspace and move it to the next appropriate state.";
|
|
126
|
+
}
|
|
127
|
+
function shortSha(commit) {
|
|
128
|
+
return commit ? commit.slice(0, 12) : "(unavailable)";
|
|
129
|
+
}
|
|
115
130
|
/**
|
|
116
131
|
* Format recent notes for inclusion in dispatch prompts.
|
|
117
132
|
* Takes last N notes, truncates each to maxLen characters, strips newlines.
|
|
@@ -128,19 +143,224 @@ function formatRecentNotes(notes, count = 3, maxLen = 200) {
|
|
|
128
143
|
});
|
|
129
144
|
return lines.join("\n");
|
|
130
145
|
}
|
|
146
|
+
class DispatchPromptError extends Error {
|
|
147
|
+
suggestion;
|
|
148
|
+
constructor(message, suggestion) {
|
|
149
|
+
super(message);
|
|
150
|
+
this.name = "DispatchPromptError";
|
|
151
|
+
this.suggestion = suggestion;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function resolveDispatchRole(trigger) {
|
|
155
|
+
return trigger === "task.pending_review" ? "reviewer" : "worker";
|
|
156
|
+
}
|
|
157
|
+
async function renderEntrypointForAdapter(entrypoint, adapterId, projectDir) {
|
|
158
|
+
const trimmed = entrypoint.trim();
|
|
159
|
+
if (!trimmed) {
|
|
160
|
+
return trimmed;
|
|
161
|
+
}
|
|
162
|
+
const portableResolved = await rewriteSkillReferencesForAdapter(trimmed, projectDir, adapterId);
|
|
163
|
+
if (portableResolved !== trimmed) {
|
|
164
|
+
return portableResolved.trim();
|
|
165
|
+
}
|
|
166
|
+
switch (adapterId) {
|
|
167
|
+
case "codex-acp":
|
|
168
|
+
return trimmed
|
|
169
|
+
.replace(/^\/kspec:([a-z0-9][a-z0-9-]*)$/i, "$kspec-$1")
|
|
170
|
+
.replace(/^\/([a-z0-9][a-z0-9-]*)$/i, "$$$1");
|
|
171
|
+
case "claude-agent-acp":
|
|
172
|
+
case "claude-code-acp":
|
|
173
|
+
return trimmed
|
|
174
|
+
.replace(/^\$kspec-([a-z0-9][a-z0-9-]*)$/i, "/kspec:$1")
|
|
175
|
+
.replace(/^\$([a-z0-9][a-z0-9-]*)$/i, "/$1");
|
|
176
|
+
default:
|
|
177
|
+
return trimmed;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async function resolveRoleEntrypoint(role, adapterId, projectDir, config) {
|
|
181
|
+
const rawEntrypoint = role === "reviewer"
|
|
182
|
+
? config.ralph.skills.pr_review
|
|
183
|
+
: config.ralph.skills.task_work;
|
|
184
|
+
const rendered = await renderEntrypointForAdapter(rawEntrypoint, adapterId, projectDir);
|
|
185
|
+
if (!rendered) {
|
|
186
|
+
throw new DispatchPromptError(`No valid ${role} entrypoint is configured for adapter "${adapterId}".`, `Set ralph.skills.${role === "reviewer" ? "pr_review" : "task_work"} in kspec.config.yaml to a non-empty workflow or skill entrypoint.`);
|
|
187
|
+
}
|
|
188
|
+
return rendered;
|
|
189
|
+
}
|
|
190
|
+
function buildPublicationInstructions(role, metadata) {
|
|
191
|
+
const lines = [
|
|
192
|
+
`Publication mode: \`${metadata.publicationMode}\``,
|
|
193
|
+
`Publish target: \`${metadata.mergeTargetBranch}\``,
|
|
194
|
+
`Canonical branch: \`${metadata.canonicalBranch}\``,
|
|
195
|
+
];
|
|
196
|
+
if (metadata.publicationMode === "pull_request") {
|
|
197
|
+
if (role === "reviewer") {
|
|
198
|
+
lines.push(`Review and merge the PR that targets \`${metadata.mergeTargetBranch}\`; do not retarget it to a different base branch.`, "If you push fixes during review, re-run the required verification on the new HEAD before merging.");
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
lines.push(`After submitting the task, create or update a PR from \`${metadata.canonicalBranch}\` into \`${metadata.mergeTargetBranch}\` using the recorded base branch as the PR target.`);
|
|
202
|
+
}
|
|
203
|
+
return lines;
|
|
204
|
+
}
|
|
205
|
+
if (metadata.publicationMode === "manual_merge") {
|
|
206
|
+
if (role === "reviewer") {
|
|
207
|
+
lines.push(`If review is clean, merge \`${metadata.canonicalBranch}\` back into \`${metadata.mergeTargetBranch}\` manually against the recorded base branch.`, `If conflicts appear, stop, run \`git merge --abort\`, and move the task to \`needs_work\` or \`blocked\` with a note describing the conflict. Do not guess at conflict resolution.`);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
lines.push(`Manual merge-back is recorded for this workspace. Submit the task for review; do not open a PR against \`${metadata.mergeTargetBranch}\`.`, `If you must prepare the merge path, keep the work on \`${metadata.canonicalBranch}\` and hand review a clean branch lineage back to \`${metadata.mergeTargetBranch}\`.`);
|
|
211
|
+
}
|
|
212
|
+
return lines;
|
|
213
|
+
}
|
|
214
|
+
throw new DispatchPromptError(`Workspace publication mode "${metadata.publicationMode}" is invalid.`, "Re-provision the dispatch workspace or repair its metadata so publicationMode is pull_request or manual_merge.");
|
|
215
|
+
}
|
|
216
|
+
async function buildRoleEntryContext(projectDir, adapterId, trigger, metadata) {
|
|
217
|
+
const role = resolveDispatchRole(trigger);
|
|
218
|
+
const { config } = await loadProjectConfig(projectDir, projectDir);
|
|
219
|
+
const entrypoint = await resolveRoleEntrypoint(role, adapterId, projectDir, config);
|
|
220
|
+
const publication = buildPublicationInstructions(role, metadata);
|
|
221
|
+
return [
|
|
222
|
+
"## Role Entry",
|
|
223
|
+
`Role: ${role}`,
|
|
224
|
+
`Workflow entrypoint: \`${entrypoint}\``,
|
|
225
|
+
`Start by executing the ${role === "reviewer" ? "review" : "work"} flow defined by \`${entrypoint}\`.`,
|
|
226
|
+
...publication,
|
|
227
|
+
].join("\n");
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Find the examined commit from the most recent closed review for a task.
|
|
231
|
+
* Returns null when no prior examined commit exists.
|
|
232
|
+
*
|
|
233
|
+
* AC: @review-fix-cycle-diff ac-2 — find prior review's examined commit
|
|
234
|
+
*/
|
|
235
|
+
export function findPriorExaminedCommit(reviews, taskRef) {
|
|
236
|
+
const cleanRef = taskRef.startsWith("@") ? taskRef.slice(1) : taskRef;
|
|
237
|
+
const taskReviews = reviews.filter((r) => r.related_refs.includes(cleanRef)
|
|
238
|
+
|| (r.subject.type === "task" && "ref" in r.subject && r.subject.ref === cleanRef));
|
|
239
|
+
const closedWithCommit = taskReviews
|
|
240
|
+
.filter((r) => r.lifecycle_state === "closed" && r.examined_commit)
|
|
241
|
+
.sort((a, b) => (b.created_at ?? "").localeCompare(a.created_at ?? ""));
|
|
242
|
+
if (closedWithCommit.length === 0)
|
|
243
|
+
return null;
|
|
244
|
+
return closedWithCommit[0].examined_commit;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Compute a git diff --stat between two commits. Returns null on any error.
|
|
248
|
+
*
|
|
249
|
+
* AC: @review-fix-cycle-diff ac-3 — graceful omission on unreachable commits
|
|
250
|
+
*/
|
|
251
|
+
export function computeDiffStat(fromCommit, toCommit, cwd) {
|
|
252
|
+
try {
|
|
253
|
+
const result = spawnSync("git", ["diff", "--stat", fromCommit, toCommit], { cwd, encoding: "utf-8", stdio: "pipe", timeout: 10_000 });
|
|
254
|
+
if (result.status !== 0)
|
|
255
|
+
return null;
|
|
256
|
+
const stat = result.stdout?.trim();
|
|
257
|
+
if (!stat)
|
|
258
|
+
return null;
|
|
259
|
+
return [
|
|
260
|
+
`Changes since prior review (${shortSha(fromCommit)}..${shortSha(toCommit)}):`,
|
|
261
|
+
stat,
|
|
262
|
+
].join("\n");
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Compute a diff summary between the prior review's examined commit and the
|
|
270
|
+
* current canonical branch head. Returns null when no prior examined commit
|
|
271
|
+
* exists or when the diff cannot be computed (unreachable commits, etc.).
|
|
272
|
+
*
|
|
273
|
+
* AC: @review-fix-cycle-diff ac-2 — diff summary for reviewer orientation
|
|
274
|
+
* AC: @review-fix-cycle-diff ac-3 — graceful omission on unreachable commits
|
|
275
|
+
*/
|
|
276
|
+
export async function getFixCycleDiffSummary(projectDir, taskRef, canonicalBranchHead, workspaceCwd) {
|
|
277
|
+
if (!canonicalBranchHead)
|
|
278
|
+
return null;
|
|
279
|
+
try {
|
|
280
|
+
const ctx = await initContext(projectDir);
|
|
281
|
+
const reviews = await loadReviewRecords(ctx);
|
|
282
|
+
const priorCommit = findPriorExaminedCommit(reviews, taskRef);
|
|
283
|
+
if (!priorCommit)
|
|
284
|
+
return null;
|
|
285
|
+
return computeDiffStat(priorCommit, canonicalBranchHead, workspaceCwd ?? projectDir);
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
// AC: @review-fix-cycle-diff ac-3 — graceful omission on any error
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
131
292
|
/**
|
|
132
293
|
* Build orientation context block for a dispatch prompt.
|
|
133
294
|
* Provides the agent with task title, trigger meaning, and relevant context.
|
|
134
295
|
*
|
|
135
296
|
* AC: @agent-dispatch-engine ac-13, ac-14, ac-15
|
|
136
297
|
*/
|
|
137
|
-
export function buildOrientationContext(taskRef, trigger,
|
|
298
|
+
export function buildOrientationContext(taskRef, trigger, workspaceOrTask, taskOrMetadata, metadataOrRole, explicitRole, options) {
|
|
299
|
+
const usingProvisionedWorkspace = typeof workspaceOrTask === "object"
|
|
300
|
+
&& workspaceOrTask !== null
|
|
301
|
+
&& "cwd" in workspaceOrTask
|
|
302
|
+
&& "metadata" in workspaceOrTask;
|
|
303
|
+
const role = explicitRole
|
|
304
|
+
?? (usingProvisionedWorkspace
|
|
305
|
+
? (trigger === "task.pending_review" ? "reviewer" : "worker")
|
|
306
|
+
: (metadataOrRole
|
|
307
|
+
?? (trigger === "task.pending_review" ? "reviewer" : "worker")));
|
|
308
|
+
const workspace = usingProvisionedWorkspace
|
|
309
|
+
? workspaceOrTask
|
|
310
|
+
: null;
|
|
311
|
+
const task = usingProvisionedWorkspace
|
|
312
|
+
? taskOrMetadata
|
|
313
|
+
: workspaceOrTask;
|
|
314
|
+
const metadata = (usingProvisionedWorkspace
|
|
315
|
+
? workspaceOrTask.metadata
|
|
316
|
+
: taskOrMetadata) ?? null;
|
|
138
317
|
const title = task?.title ?? "(unavailable)";
|
|
318
|
+
const bootstrapRoleState = metadata?.bootstrap?.roleStates?.[role];
|
|
319
|
+
const workspacePath = workspace?.cwd
|
|
320
|
+
?? (role === "reviewer" ? metadata?.reviewerWorktreeDir : metadata?.workerWorktreeDir)
|
|
321
|
+
?? "(unavailable)";
|
|
322
|
+
const workspaceMode = role === "reviewer" ? "detached review snapshot" : "mutable worker branch";
|
|
323
|
+
const bootstrapSummary = !bootstrapRoleState
|
|
324
|
+
? "not available"
|
|
325
|
+
: bootstrapRoleState.status === "succeeded"
|
|
326
|
+
? role === "reviewer" && bootstrapRoleState.steps.length === 0
|
|
327
|
+
? "reused worker bootstrap"
|
|
328
|
+
: "prepared"
|
|
329
|
+
: bootstrapRoleState.status === "failed"
|
|
330
|
+
? `failed${bootstrapRoleState.failureMessage ? ` (${bootstrapRoleState.failureMessage})` : ""}`
|
|
331
|
+
: "not run";
|
|
332
|
+
const dependencyStatus = bootstrapRoleState && bootstrapRoleState.invalidationReasons.length > 0
|
|
333
|
+
? bootstrapRoleState.invalidationReasons.join("; ")
|
|
334
|
+
: "satisfied";
|
|
335
|
+
const healthSummary = metadata?.healthStatus === "healthy"
|
|
336
|
+
? "ready"
|
|
337
|
+
: metadata?.healthReason
|
|
338
|
+
? `${metadata.healthStatus} (${metadata.healthReason})`
|
|
339
|
+
: (metadata?.healthStatus ?? "unknown");
|
|
340
|
+
const canonicalHeadContext = role === "reviewer"
|
|
341
|
+
? `${shortSha(metadata?.canonicalBranchHead)} (snapshot under review)`
|
|
342
|
+
: `${shortSha(metadata?.canonicalBranchHead)} (canonical branch head to resume)`;
|
|
139
343
|
const lines = [
|
|
140
344
|
"## Task Context",
|
|
141
345
|
`Task: ${taskRef} \u2014 "${title}"`,
|
|
142
|
-
`
|
|
346
|
+
`Selection reason: ${triggerDescription(trigger)}`,
|
|
347
|
+
`Role: ${role}`,
|
|
348
|
+
`Focus: ${focusDescription(trigger, role)}`,
|
|
349
|
+
`Workspace (your working directory): ${workspacePath}`,
|
|
350
|
+
`Workspace mode: ${workspaceMode}`,
|
|
351
|
+
`Canonical branch: ${metadata?.canonicalBranch ?? "(unavailable)"}`,
|
|
352
|
+
`Integration target: ${metadata?.integrationTargetBranch ?? metadata?.mergeTargetBranch ?? "(unavailable)"}`,
|
|
353
|
+
`Canonical head: ${canonicalHeadContext}`,
|
|
354
|
+
`Bootstrap state: ${bootstrapSummary}`,
|
|
355
|
+
`Workspace health: ${healthSummary}`,
|
|
356
|
+
`Dependency status: ${dependencyStatus}`,
|
|
143
357
|
];
|
|
358
|
+
if (role === "reviewer") {
|
|
359
|
+
lines.push(`Prepared state: Detached reviewer snapshot at ${shortSha(metadata?.canonicalBranchHead)}. The mutable worker branch remains ${metadata?.canonicalBranch ?? "(unavailable)"}.`);
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
lines.push(`Prepared state: Mutable worker worktree attached to ${metadata?.canonicalBranch ?? "(unavailable)"} under ${metadata?.worktreeRoot ?? "(unavailable)"}.`);
|
|
363
|
+
}
|
|
144
364
|
// AC: @agent-dispatch-engine ac-14 - Include recent notes for fix cycles
|
|
145
365
|
if (trigger === "task.needs_work" && task?.notes && task.notes.length > 0) {
|
|
146
366
|
const noteText = formatRecentNotes(task.notes);
|
|
@@ -152,9 +372,35 @@ export function buildOrientationContext(taskRef, trigger, task) {
|
|
|
152
372
|
if (trigger === "task.pending_review") {
|
|
153
373
|
const url = task?.review_url ?? "Not provided \u2014 find PR via task notes or git log.";
|
|
154
374
|
lines.push(`Review URL: ${url}`);
|
|
375
|
+
lines.push(`Cycle context: Review cycle on a detached snapshot. If changes are kicked back, the follow-up worker resumes ${metadata?.canonicalBranch ?? "(unavailable)"} and still publishes against ${metadata?.integrationTargetBranch ?? metadata?.mergeTargetBranch ?? "(unavailable)"}.`);
|
|
376
|
+
// AC: @review-fix-cycle-diff ac-2 — Include fix-cycle diff summary for reviewer
|
|
377
|
+
if (options?.fixCycleDiffSummary) {
|
|
378
|
+
lines.push("", "## Fix-Cycle Diff", options.fixCycleDiffSummary);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (trigger === "task.needs_work") {
|
|
382
|
+
lines.push(`Cycle context: Fix cycle after review. You are resuming ${metadata?.canonicalBranch ?? "(unavailable)"}; publication still targets ${metadata?.integrationTargetBranch ?? metadata?.mergeTargetBranch ?? "(unavailable)"}.`);
|
|
155
383
|
}
|
|
384
|
+
const publicationGuidance = metadata?.publicationMode === "pull_request"
|
|
385
|
+
? `- Publish via PR: create or update a pull request from ${metadata.canonicalBranch} into ${metadata.integrationTargetBranch}.`
|
|
386
|
+
: `- Publish via manual merge: merge ${metadata?.canonicalBranch ?? "(unavailable)"} back into ${metadata?.integrationTargetBranch ?? metadata?.mergeTargetBranch ?? "(unavailable)"}; if conflicts occur, stop and escalate with the conflict details instead of improvising.`;
|
|
387
|
+
lines.push("", "Dispatch Branch Context:", `- Canonical branch: ${metadata?.canonicalBranch ?? "(unavailable)"}`, `- Integration target: ${metadata?.integrationTargetBranch ?? metadata?.mergeTargetBranch ?? "(unavailable)"} @ ${metadata?.integrationTargetCommit ?? metadata?.baseBranchPoint ?? "(unavailable)"}`, `- Publication mode: ${metadata?.publicationMode ?? "manual_merge"}`, role === "reviewer"
|
|
388
|
+
? `- Snapshot under review: ${metadata?.canonicalBranchHead ?? "(unavailable)"}`
|
|
389
|
+
: `- Canonical head: ${metadata?.canonicalBranchHead ?? "(unavailable)"}`, publicationGuidance);
|
|
156
390
|
return lines.join("\n");
|
|
157
391
|
}
|
|
392
|
+
function resolveCleanupStateForTaskChange(change) {
|
|
393
|
+
if (change.toStatus === "completed") {
|
|
394
|
+
return { integrationState: "merged", taskStatus: "completed" };
|
|
395
|
+
}
|
|
396
|
+
if (change.toStatus === "cancelled") {
|
|
397
|
+
return { integrationState: "abandoned", taskStatus: "cancelled" };
|
|
398
|
+
}
|
|
399
|
+
if (change.fromStatus === "completed" || change.fromStatus === "cancelled") {
|
|
400
|
+
return { integrationState: "reset", taskStatus: change.toStatus };
|
|
401
|
+
}
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
158
404
|
// ─── DispatchEngine ───────────────────────────────────────────────────────────
|
|
159
405
|
/**
|
|
160
406
|
* The core dispatch runtime.
|
|
@@ -173,9 +419,13 @@ export class DispatchEngine {
|
|
|
173
419
|
cwd;
|
|
174
420
|
dedupWindowMs;
|
|
175
421
|
reconcileIntervalMs;
|
|
422
|
+
/** AC: @per-task-dispatch-drain-coalescing ac-4 */
|
|
423
|
+
coalesceWindowMs;
|
|
176
424
|
kspecCliPath;
|
|
177
425
|
onInvocationEvent;
|
|
178
|
-
|
|
426
|
+
onSessionEvent;
|
|
427
|
+
/** Per-session text accumulator for newline-boundary streaming. */
|
|
428
|
+
accumulator = new SessionEventAccumulator();
|
|
179
429
|
/** Queue of pending dispatch entries, per agent id */
|
|
180
430
|
queues = new Map();
|
|
181
431
|
/** Count of active (running) invocations per agent id */
|
|
@@ -194,10 +444,22 @@ export class DispatchEngine {
|
|
|
194
444
|
invocationAbortControllers = new Set();
|
|
195
445
|
/** Per-invocation tracking records for status display */
|
|
196
446
|
activeInvocationDetails = new Map();
|
|
447
|
+
/** Task refs currently between queue removal and active tracking registration */
|
|
448
|
+
inFlightTaskKeys = new Set();
|
|
197
449
|
/** Monotonic enqueue sequence for deterministic queue ordering */
|
|
198
450
|
nextQueueSequence = 0;
|
|
451
|
+
/** Last task selected/completed, used as continuity affinity signal. */
|
|
452
|
+
recentTaskAffinityRef = null;
|
|
199
453
|
/** Timer handle for periodic reconciliation. AC: @agent-dispatch-engine ac-20 */
|
|
200
454
|
reconcileTimer = null;
|
|
455
|
+
/** All in-flight reconciliation promises so stop() can await every one. */
|
|
456
|
+
inFlightReconciles = new Set();
|
|
457
|
+
/** Per-task coalescing timers. AC: @per-task-dispatch-drain-coalescing ac-1 */
|
|
458
|
+
coalesceTimers = new Map();
|
|
459
|
+
/** Whether a drain is currently in progress. AC: @per-task-dispatch-drain-coalescing ac-8 */
|
|
460
|
+
drainInProgress = false;
|
|
461
|
+
/** Whether another drain was requested while one is already running. AC: @per-task-dispatch-drain-coalescing ac-8 */
|
|
462
|
+
drainPending = false;
|
|
201
463
|
constructor(options) {
|
|
202
464
|
this.projectDir = options.projectDir;
|
|
203
465
|
this.specDir = options.specDir ?? path.join(options.projectDir, ".kspec");
|
|
@@ -206,9 +468,11 @@ export class DispatchEngine {
|
|
|
206
468
|
this.reconcileIntervalMs = (options.reconcileIntervalMs === null || options.reconcileIntervalMs === 0)
|
|
207
469
|
? 0
|
|
208
470
|
: (options.reconcileIntervalMs ?? 60_000);
|
|
471
|
+
// AC: @per-task-dispatch-drain-coalescing ac-4
|
|
472
|
+
this.coalesceWindowMs = options.coalesceWindowMs ?? 5000;
|
|
209
473
|
this.kspecCliPath = options.kspecCliPath;
|
|
210
474
|
this.onInvocationEvent = options.onInvocationEvent;
|
|
211
|
-
this.
|
|
475
|
+
this.onSessionEvent = options.onSessionEvent;
|
|
212
476
|
}
|
|
213
477
|
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
214
478
|
/**
|
|
@@ -219,15 +483,32 @@ export class DispatchEngine {
|
|
|
219
483
|
*/
|
|
220
484
|
async start() {
|
|
221
485
|
this.running = true;
|
|
486
|
+
try {
|
|
487
|
+
const ctx = await initContext(this.projectDir);
|
|
488
|
+
const tasks = await loadAllTasks(ctx);
|
|
489
|
+
const taskStatusByRef = new Map(tasks.map((task) => [`@${task._ulid}`, task.status]));
|
|
490
|
+
await this.shadowMutex.runExclusive(async () => {
|
|
491
|
+
await reconcileDispatchWorkspaceRegistry(this.projectDir, taskStatusByRef);
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
catch (err) {
|
|
495
|
+
console.error("[dispatch] Workspace registry reconciliation error:", err);
|
|
496
|
+
}
|
|
497
|
+
await reconcileDispatchWorkspaceArtifacts(this.projectDir, {
|
|
498
|
+
activeTaskRefs: this._activeTaskRefs(),
|
|
499
|
+
});
|
|
222
500
|
// AC: @agent-dispatch-engine ac-8 - Bootstrap: evaluate existing task states
|
|
223
501
|
await this._bootstrap();
|
|
224
502
|
// AC: @agent-dispatch-engine ac-19, ac-20 - Start periodic reconciliation
|
|
225
503
|
if (this.reconcileIntervalMs > 0) {
|
|
226
504
|
this.reconcileTimer = setInterval(() => {
|
|
227
505
|
if (this.running) {
|
|
228
|
-
this._reconcile().catch((err) => {
|
|
506
|
+
const p = this._reconcile().catch((err) => {
|
|
229
507
|
console.error("[dispatch] Reconciliation error:", err);
|
|
508
|
+
}).finally(() => {
|
|
509
|
+
this.inFlightReconciles.delete(p);
|
|
230
510
|
});
|
|
511
|
+
this.inFlightReconciles.add(p);
|
|
231
512
|
}
|
|
232
513
|
}, this.reconcileIntervalMs);
|
|
233
514
|
this.reconcileTimer.unref();
|
|
@@ -246,21 +527,54 @@ export class DispatchEngine {
|
|
|
246
527
|
return;
|
|
247
528
|
}
|
|
248
529
|
this._recordEvent(change);
|
|
530
|
+
const cleanupState = resolveCleanupStateForTaskChange(change);
|
|
531
|
+
if (cleanupState) {
|
|
532
|
+
try {
|
|
533
|
+
await this.shadowMutex.runExclusive(async () => {
|
|
534
|
+
await reconcileDispatchWorkspaceLifecycle({
|
|
535
|
+
projectDir: this.projectDir,
|
|
536
|
+
taskRef: change.taskRef,
|
|
537
|
+
task: change.task
|
|
538
|
+
? {
|
|
539
|
+
title: change.task.title,
|
|
540
|
+
slugs: change.task.slugs,
|
|
541
|
+
}
|
|
542
|
+
: undefined,
|
|
543
|
+
cleanupState,
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
catch (err) {
|
|
548
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
549
|
+
console.error(`[dispatch] Failed to reconcile workspace lifecycle for ${change.taskRef}: ${message}`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
249
552
|
// AC: @agent-dispatch-engine ac-1 - Match against dispatch rules
|
|
250
553
|
const agents = await this._loadAgents();
|
|
251
554
|
const eventType = STATUS_TO_EVENT[change.toStatus];
|
|
252
555
|
if (!eventType)
|
|
253
556
|
return;
|
|
254
|
-
// Load
|
|
557
|
+
// Load all tasks for filter evaluation (needed for dependency checks)
|
|
558
|
+
let allTasks;
|
|
255
559
|
let taskData = change.task;
|
|
256
560
|
if (!taskData && change.taskId) {
|
|
257
561
|
try {
|
|
258
562
|
const ctx = await initContext(this.projectDir);
|
|
259
|
-
|
|
260
|
-
taskData =
|
|
563
|
+
allTasks = await loadAllTasks(ctx);
|
|
564
|
+
taskData = allTasks.find((t) => t._ulid === change.taskId);
|
|
261
565
|
}
|
|
262
566
|
catch {
|
|
263
|
-
// Can't load
|
|
567
|
+
// Can't load tasks, filter evaluation will be lenient
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
// Load allTasks for dependency checking even when task data was provided
|
|
571
|
+
if (!allTasks && taskData) {
|
|
572
|
+
try {
|
|
573
|
+
const ctx = await initContext(this.projectDir);
|
|
574
|
+
allTasks = await loadAllTasks(ctx);
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
// Can't load tasks, dependency check will be skipped
|
|
264
578
|
}
|
|
265
579
|
}
|
|
266
580
|
// Make loaded task available for prompt building (AC: @agent-dispatch-engine ac-13)
|
|
@@ -272,14 +586,22 @@ export class DispatchEngine {
|
|
|
272
586
|
if (rule.on !== eventType)
|
|
273
587
|
continue;
|
|
274
588
|
// AC: @agent-dispatch-engine ac-6 - Apply filters
|
|
275
|
-
if (!this._matchesFilter(change, rule, taskData))
|
|
589
|
+
if (!this._matchesFilter(change, rule, taskData, allTasks))
|
|
276
590
|
continue;
|
|
277
591
|
// AC: @agent-dispatch-engine ac-2 - Each matching agent queued independently
|
|
278
592
|
this._enqueue(agent, change);
|
|
279
593
|
}
|
|
280
594
|
}
|
|
281
|
-
//
|
|
282
|
-
|
|
595
|
+
// AC: @per-task-dispatch-drain-coalescing ac-1, ac-4, ac-6
|
|
596
|
+
// Schedule a per-task coalescing timer instead of draining immediately.
|
|
597
|
+
// If coalesceWindowMs is 0, drain immediately for backward compatibility.
|
|
598
|
+
// AC: @agent-dispatch-engine ac-27 — all drains go through _serializedDrain()
|
|
599
|
+
if (this.coalesceWindowMs <= 0) {
|
|
600
|
+
await this._serializedDrain();
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
this._scheduleCoalescedDrain(change.taskId);
|
|
604
|
+
}
|
|
283
605
|
}
|
|
284
606
|
/**
|
|
285
607
|
* Handle file watcher notification: diff previous vs current task states.
|
|
@@ -327,20 +649,39 @@ export class DispatchEngine {
|
|
|
327
649
|
*/
|
|
328
650
|
async stop() {
|
|
329
651
|
this.running = false;
|
|
652
|
+
// AC: @per-task-dispatch-drain-coalescing ac-5 - Cancel all pending coalescing timers
|
|
653
|
+
for (const timer of this.coalesceTimers.values()) {
|
|
654
|
+
clearTimeout(timer);
|
|
655
|
+
}
|
|
656
|
+
this.coalesceTimers.clear();
|
|
330
657
|
// AC: @agent-dispatch-engine ac-20 - Stop periodic reconciliation
|
|
331
658
|
if (this.reconcileTimer !== null) {
|
|
332
659
|
clearInterval(this.reconcileTimer);
|
|
333
660
|
this.reconcileTimer = null;
|
|
334
661
|
}
|
|
662
|
+
// Wait for ALL in-flight reconciliations to finish so none
|
|
663
|
+
// write files after stop() returns (prevents ENOTEMPTY in test teardown).
|
|
664
|
+
if (this.inFlightReconciles.size > 0) {
|
|
665
|
+
await Promise.allSettled(Array.from(this.inFlightReconciles));
|
|
666
|
+
this.inFlightReconciles.clear();
|
|
667
|
+
}
|
|
668
|
+
// Clear queues BEFORE awaiting invocations so completion handlers
|
|
669
|
+
// that call _drainQueues find nothing to spawn. This prevents
|
|
670
|
+
// second-generation invocations from being added to runningInvocations
|
|
671
|
+
// after our snapshot, eliminating the need for a while loop (which
|
|
672
|
+
// risks hanging indefinitely if an invocation never resolves).
|
|
673
|
+
this.queues.clear();
|
|
335
674
|
// AC: @agent-dispatch-engine ac-11 - Send graceful cancel to all active invocations
|
|
336
675
|
for (const controller of this.invocationAbortControllers) {
|
|
337
676
|
controller.abort();
|
|
338
677
|
}
|
|
339
|
-
// Wait for all running invocations to complete (or abort)
|
|
678
|
+
// Wait for all running invocations to complete (or abort).
|
|
679
|
+
// Safe as a single pass: queues are already cleared above, and
|
|
680
|
+
// _spawnInvocation guards with !this.running, so no new promises
|
|
681
|
+
// can be added to runningInvocations during this await.
|
|
340
682
|
if (this.runningInvocations.size > 0) {
|
|
341
683
|
await Promise.allSettled(Array.from(this.runningInvocations));
|
|
342
684
|
}
|
|
343
|
-
this.queues.clear();
|
|
344
685
|
this.activeCount.clear();
|
|
345
686
|
this.recentEvents.clear();
|
|
346
687
|
this.invocationAbortControllers.clear();
|
|
@@ -353,6 +694,9 @@ export class DispatchEngine {
|
|
|
353
694
|
getShadowMutex() {
|
|
354
695
|
return this.shadowMutex;
|
|
355
696
|
}
|
|
697
|
+
getCwd() {
|
|
698
|
+
return this.cwd;
|
|
699
|
+
}
|
|
356
700
|
/**
|
|
357
701
|
* Returns current engine status info including per-invocation details.
|
|
358
702
|
* AC: @cli-agent-commands ac-6
|
|
@@ -390,8 +734,8 @@ export class DispatchEngine {
|
|
|
390
734
|
try {
|
|
391
735
|
const enqueued = await this._evaluateAllTasks({ skipIfActive: false });
|
|
392
736
|
if (enqueued > 0) {
|
|
393
|
-
|
|
394
|
-
await this.
|
|
737
|
+
// AC: @agent-dispatch-engine ac-27 — all drains go through _serializedDrain()
|
|
738
|
+
await this._serializedDrain();
|
|
395
739
|
}
|
|
396
740
|
}
|
|
397
741
|
catch (err) {
|
|
@@ -404,11 +748,25 @@ export class DispatchEngine {
|
|
|
404
748
|
* AC: @agent-dispatch-engine ac-19
|
|
405
749
|
*/
|
|
406
750
|
async _reconcile() {
|
|
751
|
+
try {
|
|
752
|
+
const ctx = await initContext(this.projectDir);
|
|
753
|
+
const tasks = await loadAllTasks(ctx);
|
|
754
|
+
const taskStatusByRef = new Map(tasks.map((task) => [`@${task._ulid}`, task.status]));
|
|
755
|
+
await this.shadowMutex.runExclusive(async () => {
|
|
756
|
+
await reconcileDispatchWorkspaceRegistry(this.projectDir, taskStatusByRef, this._activeRoleByTaskRef());
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
catch (err) {
|
|
760
|
+
console.error("[dispatch] Workspace registry reconciliation error:", err);
|
|
761
|
+
}
|
|
762
|
+
await reconcileDispatchWorkspaceArtifacts(this.projectDir, {
|
|
763
|
+
activeTaskRefs: this._activeTaskRefs(),
|
|
764
|
+
});
|
|
407
765
|
const enqueued = await this._evaluateAllTasks({ skipIfActive: true });
|
|
408
766
|
if (enqueued > 0) {
|
|
409
767
|
console.log(`[dispatch] Reconciliation enqueued ${enqueued} task(s)`);
|
|
410
|
-
|
|
411
|
-
await this.
|
|
768
|
+
// AC: @agent-dispatch-engine ac-27 — all drains go through _serializedDrain()
|
|
769
|
+
await this._serializedDrain();
|
|
412
770
|
}
|
|
413
771
|
}
|
|
414
772
|
/**
|
|
@@ -447,7 +805,7 @@ export class DispatchEngine {
|
|
|
447
805
|
timestamp: now,
|
|
448
806
|
task,
|
|
449
807
|
};
|
|
450
|
-
if (!this._matchesFilter(change, rule, task))
|
|
808
|
+
if (!this._matchesFilter(change, rule, task, tasks))
|
|
451
809
|
continue;
|
|
452
810
|
if (opts.skipIfActive && this._hasActiveOrQueuedInvocation(agent.id, task._ulid))
|
|
453
811
|
continue;
|
|
@@ -463,6 +821,9 @@ export class DispatchEngine {
|
|
|
463
821
|
* AC: @agent-dispatch-engine ac-19
|
|
464
822
|
*/
|
|
465
823
|
_hasActiveOrQueuedInvocation(agentId, taskId) {
|
|
824
|
+
if (this.inFlightTaskKeys.has(`${agentId}:@${taskId}`)) {
|
|
825
|
+
return true;
|
|
826
|
+
}
|
|
466
827
|
// Check active invocations
|
|
467
828
|
for (const record of this.activeInvocationDetails.values()) {
|
|
468
829
|
if (record.agentId === agentId && record.taskRef === `@${taskId}`) {
|
|
@@ -473,6 +834,46 @@ export class DispatchEngine {
|
|
|
473
834
|
const queue = this.queues.get(agentId) ?? [];
|
|
474
835
|
return queue.some((entry) => entry.change.taskId === taskId);
|
|
475
836
|
}
|
|
837
|
+
_activeRoleByTaskRef() {
|
|
838
|
+
const roles = new Map();
|
|
839
|
+
for (const record of this.activeInvocationDetails.values()) {
|
|
840
|
+
if (record.taskRef) {
|
|
841
|
+
roles.set(record.taskRef, record.role);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return roles;
|
|
845
|
+
}
|
|
846
|
+
_activeTaskRefs() {
|
|
847
|
+
const refs = new Set();
|
|
848
|
+
for (const record of this.activeInvocationDetails.values()) {
|
|
849
|
+
if (record.taskRef) {
|
|
850
|
+
refs.add(record.taskRef);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return refs;
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Check whether any agent has an active or in-flight invocation for a task.
|
|
857
|
+
* Considers both registered active invocations (activeInvocationDetails) and
|
|
858
|
+
* tasks that are between queue removal and active registration (inFlightTaskKeys).
|
|
859
|
+
*
|
|
860
|
+
* AC: @agent-dispatch-engine ac-26
|
|
861
|
+
*/
|
|
862
|
+
_hasActiveInvocationForTask(taskRef) {
|
|
863
|
+
// Check active invocations across all agents
|
|
864
|
+
for (const record of this.activeInvocationDetails.values()) {
|
|
865
|
+
if (record.taskRef === taskRef) {
|
|
866
|
+
return true;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
// Check in-flight keys (format: "agentId:taskRef") across all agents
|
|
870
|
+
for (const key of this.inFlightTaskKeys) {
|
|
871
|
+
if (key.endsWith(`:${taskRef}`)) {
|
|
872
|
+
return true;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return false;
|
|
876
|
+
}
|
|
476
877
|
/**
|
|
477
878
|
* Load agent definitions from meta context.
|
|
478
879
|
*/
|
|
@@ -488,19 +889,42 @@ export class DispatchEngine {
|
|
|
488
889
|
}
|
|
489
890
|
/**
|
|
490
891
|
* Check if a state change matches a dispatch rule's filters.
|
|
892
|
+
* Base readiness (deps, blocked_by) is checked before consumer filters
|
|
893
|
+
* per @trait-task-readiness ac-composable.
|
|
894
|
+
*
|
|
491
895
|
* AC: @agent-dispatch-engine ac-6
|
|
896
|
+
* AC: @agent-dispatch-engine ac-21
|
|
897
|
+
* AC: @trait-task-readiness ac-deps
|
|
898
|
+
* AC: @trait-task-readiness ac-not-blocked
|
|
899
|
+
* AC: @trait-task-readiness ac-composable
|
|
492
900
|
*/
|
|
493
|
-
_matchesFilter(change, rule, task) {
|
|
494
|
-
|
|
495
|
-
|
|
901
|
+
_matchesFilter(change, rule, task, allTasks) {
|
|
902
|
+
// AC: @agent-dispatch-engine ac-21 — default to automation: eligible for
|
|
903
|
+
// task.ready and task.needs_work when no filter is specified
|
|
904
|
+
const defaultsToEligible = rule.on === "task.ready" || rule.on === "task.needs_work";
|
|
496
905
|
// We need the task to evaluate filters — if not provided, reject to avoid
|
|
497
906
|
// enqueuing non-matching tasks (AC-6: all filters must match)
|
|
498
907
|
if (!task)
|
|
908
|
+
return !rule.filter && !defaultsToEligible;
|
|
909
|
+
// Any unresolved blocker excludes the candidate from scheduling.
|
|
910
|
+
if ((task.blocked_by ?? []).length > 0) {
|
|
499
911
|
return false;
|
|
500
|
-
|
|
912
|
+
}
|
|
913
|
+
// Any unresolved dependency excludes the candidate from scheduling.
|
|
914
|
+
if (allTasks && (task.depends_on ?? []).length > 0) {
|
|
915
|
+
if (!areDependenciesMet(task, allTasks)) {
|
|
916
|
+
return false;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
if (!rule.filter && !defaultsToEligible)
|
|
920
|
+
return true;
|
|
921
|
+
// AC: @trait-task-readiness ac-composable — consumer filters applied after base readiness
|
|
922
|
+
const filter = rule.filter ?? {};
|
|
923
|
+
// Apply default automation filter for task.ready/task.needs_work
|
|
924
|
+
const effectiveAutomation = filter.automation ?? (defaultsToEligible ? "eligible" : undefined);
|
|
501
925
|
// Automation filter
|
|
502
|
-
if (
|
|
503
|
-
if (task.automation !==
|
|
926
|
+
if (effectiveAutomation !== undefined) {
|
|
927
|
+
if (task.automation !== effectiveAutomation) {
|
|
504
928
|
return false;
|
|
505
929
|
}
|
|
506
930
|
}
|
|
@@ -511,9 +935,11 @@ export class DispatchEngine {
|
|
|
511
935
|
return false;
|
|
512
936
|
}
|
|
513
937
|
}
|
|
514
|
-
// Priority filter
|
|
938
|
+
// Priority filter — threshold semantics: task priority at or above (numerically <=)
|
|
939
|
+
// AC: @ui-agent-dispatch ac-8
|
|
515
940
|
if (filter.priority !== undefined) {
|
|
516
|
-
|
|
941
|
+
const taskPriority = task.priority;
|
|
942
|
+
if (taskPriority === undefined || taskPriority > filter.priority) {
|
|
517
943
|
return false;
|
|
518
944
|
}
|
|
519
945
|
}
|
|
@@ -566,6 +992,7 @@ export class DispatchEngine {
|
|
|
566
992
|
nextRetryAt: 0,
|
|
567
993
|
enqueuedAtMs: Date.now(),
|
|
568
994
|
sequence: this.nextQueueSequence++,
|
|
995
|
+
starvationDeferrals: 0,
|
|
569
996
|
};
|
|
570
997
|
this._insertQueueEntry(queue, entry);
|
|
571
998
|
this.queues.set(agent.id, queue);
|
|
@@ -583,90 +1010,320 @@ export class DispatchEngine {
|
|
|
583
1010
|
queue.splice(insertAt, 0, entry);
|
|
584
1011
|
}
|
|
585
1012
|
/**
|
|
586
|
-
* Compare queue entries by dispatch precedence,
|
|
1013
|
+
* Compare queue entries by dispatch precedence, numeric task priority, then FIFO.
|
|
587
1014
|
* AC: @dispatch-in-progress-priority ac-1
|
|
588
1015
|
*/
|
|
589
1016
|
_compareQueueEntries(a, b) {
|
|
590
1017
|
const statusDelta = STATUS_PRECEDENCE[a.change.toStatus] - STATUS_PRECEDENCE[b.change.toStatus];
|
|
591
1018
|
if (statusDelta !== 0)
|
|
592
1019
|
return statusDelta;
|
|
1020
|
+
const priorityDelta = this._taskPriorityForEntry(a) - this._taskPriorityForEntry(b);
|
|
1021
|
+
if (priorityDelta !== 0)
|
|
1022
|
+
return priorityDelta;
|
|
593
1023
|
return a.sequence - b.sequence;
|
|
594
1024
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
1025
|
+
_taskPriorityForEntry(entry) {
|
|
1026
|
+
return entry.change.task?.priority ?? 3;
|
|
1027
|
+
}
|
|
1028
|
+
_hasContinuityAffinity(entry) {
|
|
1029
|
+
if (!this.recentTaskAffinityRef)
|
|
1030
|
+
return false;
|
|
1031
|
+
return entry.change.taskRef === this.recentTaskAffinityRef;
|
|
1032
|
+
}
|
|
1033
|
+
_compareSchedulerCandidates(a, b) {
|
|
1034
|
+
const statusDelta = STATUS_PRECEDENCE[a.entry.change.toStatus] - STATUS_PRECEDENCE[b.entry.change.toStatus];
|
|
1035
|
+
if (statusDelta !== 0) {
|
|
1036
|
+
return statusDelta;
|
|
1037
|
+
}
|
|
1038
|
+
const priorityDelta = this._taskPriorityForEntry(a.entry) - this._taskPriorityForEntry(b.entry);
|
|
1039
|
+
if (priorityDelta !== 0) {
|
|
1040
|
+
return priorityDelta;
|
|
1041
|
+
}
|
|
1042
|
+
const sameBand = STATUS_PRECEDENCE[a.entry.change.toStatus] === STATUS_PRECEDENCE[b.entry.change.toStatus];
|
|
1043
|
+
const samePriority = this._taskPriorityForEntry(a.entry) === this._taskPriorityForEntry(b.entry);
|
|
1044
|
+
if (sameBand && samePriority) {
|
|
1045
|
+
const aAffinity = this._hasContinuityAffinity(a.entry);
|
|
1046
|
+
const bAffinity = this._hasContinuityAffinity(b.entry);
|
|
1047
|
+
if (aAffinity !== bAffinity) {
|
|
1048
|
+
if (aAffinity && b.entry.starvationDeferrals < CONTINUITY_STARVATION_THRESHOLD) {
|
|
1049
|
+
return -1;
|
|
1050
|
+
}
|
|
1051
|
+
if (bAffinity && a.entry.starvationDeferrals < CONTINUITY_STARVATION_THRESHOLD) {
|
|
1052
|
+
return 1;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
return a.entry.sequence - b.entry.sequence;
|
|
1057
|
+
}
|
|
1058
|
+
_recordContinuityDeferrals(selected, candidates) {
|
|
1059
|
+
const selectedAffinity = this._hasContinuityAffinity(selected.entry);
|
|
1060
|
+
if (!selectedAffinity) {
|
|
1061
|
+
selected.entry.starvationDeferrals = 0;
|
|
602
1062
|
return;
|
|
603
|
-
// AC: @agent-dispatch-engine ac-17 - Load current task states once for staleness checks
|
|
604
|
-
let currentTaskStates;
|
|
605
|
-
try {
|
|
606
|
-
const ctx = await initContext(this.projectDir);
|
|
607
|
-
const tasks = await loadAllTasks(ctx);
|
|
608
|
-
currentTaskStates = new Map(tasks.map((t) => [t._ulid, t.status]));
|
|
609
1063
|
}
|
|
610
|
-
|
|
611
|
-
|
|
1064
|
+
const selectedBand = STATUS_PRECEDENCE[selected.entry.change.toStatus];
|
|
1065
|
+
const selectedPriority = this._taskPriorityForEntry(selected.entry);
|
|
1066
|
+
for (const candidate of candidates) {
|
|
1067
|
+
if (candidate.entry === selected.entry)
|
|
1068
|
+
continue;
|
|
1069
|
+
const sameBand = STATUS_PRECEDENCE[candidate.entry.change.toStatus] === selectedBand;
|
|
1070
|
+
const samePriority = this._taskPriorityForEntry(candidate.entry) === selectedPriority;
|
|
1071
|
+
if (!sameBand || !samePriority)
|
|
1072
|
+
continue;
|
|
1073
|
+
if (this._hasContinuityAffinity(candidate.entry))
|
|
1074
|
+
continue;
|
|
1075
|
+
candidate.entry.starvationDeferrals += 1;
|
|
612
1076
|
}
|
|
1077
|
+
selected.entry.starvationDeferrals = 0;
|
|
1078
|
+
}
|
|
1079
|
+
// AC: @review-and-fix-cycle-workspace-discovery-before-discard ac-1, ac-2, ac-3, ac-4
|
|
1080
|
+
async _workspaceCandidateHealth(entry) {
|
|
1081
|
+
const role = entry.change.toStatus === "pending_review" ? "reviewer" : "worker";
|
|
1082
|
+
const taskInfo = entry.change.task
|
|
1083
|
+
? {
|
|
1084
|
+
title: entry.change.task.title,
|
|
1085
|
+
slugs: entry.change.task.slugs,
|
|
1086
|
+
}
|
|
1087
|
+
: undefined;
|
|
1088
|
+
const health = await getDispatchWorkspaceHealth({
|
|
1089
|
+
projectDir: this.projectDir,
|
|
1090
|
+
taskRef: entry.change.taskRef,
|
|
1091
|
+
task: taskInfo,
|
|
1092
|
+
role,
|
|
1093
|
+
});
|
|
1094
|
+
// AC: @adopt-existing-task-branch-lineage ac-1 — when workspace doesn't exist
|
|
1095
|
+
// but the task has submission linkage, allow provisioning to adopt the branch.
|
|
1096
|
+
const hasSubmissionLinkage = Boolean(entry.change.task?.submission_linkage?.branch);
|
|
1097
|
+
const eligible = !health.exists
|
|
1098
|
+
? (entry.change.toStatus !== "in_progress" && entry.change.toStatus !== "pending_review") || hasSubmissionLinkage
|
|
1099
|
+
: health.healthy;
|
|
1100
|
+
// For pending_review and needs_work tasks, attempt workspace discovery
|
|
1101
|
+
// before discarding the queue entry as missing or ineligible.
|
|
1102
|
+
if (!eligible &&
|
|
1103
|
+
(entry.change.toStatus === "pending_review" || entry.change.toStatus === "needs_work")) {
|
|
1104
|
+
const discoveryResult = await discoverWorkspaceForReviewOrFixCycle({
|
|
1105
|
+
projectDir: this.projectDir,
|
|
1106
|
+
taskRef: entry.change.taskRef,
|
|
1107
|
+
role,
|
|
1108
|
+
task: entry.change.task
|
|
1109
|
+
? {
|
|
1110
|
+
title: entry.change.task.title,
|
|
1111
|
+
slugs: entry.change.task.slugs,
|
|
1112
|
+
submission_linkage: entry.change.task.submission_linkage ?? undefined,
|
|
1113
|
+
review_url: entry.change.task.review_url,
|
|
1114
|
+
}
|
|
1115
|
+
: undefined,
|
|
1116
|
+
});
|
|
1117
|
+
// Emit diagnostics for failed discovery (AC-3) or conflicting signals (AC-4).
|
|
1118
|
+
for (const diagnostic of discoveryResult.diagnostics) {
|
|
1119
|
+
console.log(`[dispatch] Workspace discovery diagnostic for ${diagnostic.taskRef}: [${diagnostic.code}] ${diagnostic.message}`);
|
|
1120
|
+
console.log(`[dispatch] Suggestion: ${diagnostic.suggestion}`);
|
|
1121
|
+
}
|
|
1122
|
+
if (discoveryResult.recovered) {
|
|
1123
|
+
// AC-2: Recovery succeeded — re-evaluate eligibility with recovered workspace.
|
|
1124
|
+
return {
|
|
1125
|
+
eligible: true,
|
|
1126
|
+
exists: discoveryResult.health.exists,
|
|
1127
|
+
reason: discoveryResult.health.reason,
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
// Discovery failed — return ineligible with diagnostic-enriched reason.
|
|
1131
|
+
const diagnosticReason = discoveryResult.diagnostics[0]?.code
|
|
1132
|
+
?? (health.exists ? health.reason : "workspace-missing-no-recovery");
|
|
1133
|
+
return {
|
|
1134
|
+
eligible: false,
|
|
1135
|
+
exists: health.exists || discoveryResult.health.exists,
|
|
1136
|
+
reason: diagnosticReason,
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
return {
|
|
1140
|
+
eligible,
|
|
1141
|
+
exists: health.exists,
|
|
1142
|
+
reason: health.reason,
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
async _pruneIneligibleQueueEntries(agents, currentTasks, currentTaskStates) {
|
|
1146
|
+
const tasksById = new Map((currentTasks ?? []).map((task) => [task._ulid, task]));
|
|
613
1147
|
for (const agent of agents) {
|
|
614
|
-
const maxConcurrent = agent.concurrency?.max_concurrent ?? 1;
|
|
615
|
-
const active = this.activeCount.get(agent.id) ?? 0;
|
|
616
1148
|
const queue = this.queues.get(agent.id) ?? [];
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
const
|
|
623
|
-
|
|
624
|
-
const entry = queue[i];
|
|
625
|
-
const currentStatus = currentTaskStates.get(entry.change.taskId);
|
|
626
|
-
const expectedEvent = STATUS_TO_EVENT[entry.change.toStatus];
|
|
627
|
-
if (!expectedEvent)
|
|
628
|
-
continue; // No event mapping — skip check
|
|
1149
|
+
const before = queue.length;
|
|
1150
|
+
const discardedDetails = [];
|
|
1151
|
+
for (let i = queue.length - 1; i >= 0; i--) {
|
|
1152
|
+
const entry = queue[i];
|
|
1153
|
+
const currentStatus = currentTaskStates?.get(entry.change.taskId);
|
|
1154
|
+
const expectedEvent = STATUS_TO_EVENT[entry.change.toStatus];
|
|
1155
|
+
if (expectedEvent) {
|
|
629
1156
|
if (currentStatus === undefined) {
|
|
630
|
-
// Task not on disk — only discard if we previously knew about it
|
|
631
|
-
// (it was deleted). Tasks from pure handleStateChange events without
|
|
632
|
-
// on-disk presence should still be processed.
|
|
633
1157
|
if (this.prevTaskStates.has(entry.change.taskId)) {
|
|
1158
|
+
discardedDetails.push(`${entry.change.taskRef} for agent "${agent.id}": task no longer exists on disk`);
|
|
634
1159
|
queue.splice(i, 1);
|
|
1160
|
+
continue;
|
|
635
1161
|
}
|
|
636
1162
|
}
|
|
637
1163
|
else {
|
|
638
1164
|
const currentEvent = STATUS_TO_EVENT[currentStatus];
|
|
639
1165
|
if (currentEvent !== expectedEvent) {
|
|
1166
|
+
discardedDetails.push(`${entry.change.taskRef} for agent "${agent.id}": task state changed to ${currentStatus}`);
|
|
640
1167
|
queue.splice(i, 1);
|
|
1168
|
+
continue;
|
|
641
1169
|
}
|
|
642
1170
|
}
|
|
643
1171
|
}
|
|
644
|
-
|
|
645
|
-
|
|
1172
|
+
const currentTask = tasksById.get(entry.change.taskId);
|
|
1173
|
+
if (currentTask) {
|
|
1174
|
+
entry.change.task = currentTask;
|
|
1175
|
+
if (currentTask.blocked_by.length > 0) {
|
|
1176
|
+
discardedDetails.push(`${entry.change.taskRef} for agent "${agent.id}": task is blocked by ${currentTask.blocked_by.join(", ")}`);
|
|
1177
|
+
queue.splice(i, 1);
|
|
1178
|
+
continue;
|
|
1179
|
+
}
|
|
1180
|
+
if (currentTask.depends_on.length > 0 &&
|
|
1181
|
+
currentTasks &&
|
|
1182
|
+
!areDependenciesMet(currentTask, currentTasks)) {
|
|
1183
|
+
discardedDetails.push(`${entry.change.taskRef} for agent "${agent.id}": dependencies are no longer satisfied`);
|
|
1184
|
+
queue.splice(i, 1);
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
646
1187
|
}
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
const nextReadyIndex = queue.findIndex((entry) => entry.nextRetryAt <= now);
|
|
652
|
-
if (nextReadyIndex === -1) {
|
|
653
|
-
break;
|
|
1188
|
+
const workspaceHealth = await this._workspaceCandidateHealth(entry);
|
|
1189
|
+
if (!workspaceHealth.eligible) {
|
|
1190
|
+
discardedDetails.push(`${entry.change.taskRef} for agent "${agent.id}": workspace ${workspaceHealth.exists ? "is unhealthy" : "is missing"}${workspaceHealth.reason ? ` (${workspaceHealth.reason})` : ""}`);
|
|
1191
|
+
queue.splice(i, 1);
|
|
654
1192
|
}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
1193
|
+
}
|
|
1194
|
+
for (const detail of discardedDetails) {
|
|
1195
|
+
console.log(`[dispatch] Discarded queue entry ${detail}`);
|
|
1196
|
+
}
|
|
1197
|
+
if (before > queue.length) {
|
|
1198
|
+
console.log(`[dispatch] Discarded ${before - queue.length} ineligible queue entr${before - queue.length === 1 ? "y" : "ies"} for agent "${agent.id}"`);
|
|
659
1199
|
}
|
|
660
1200
|
this.queues.set(agent.id, queue);
|
|
661
1201
|
}
|
|
662
1202
|
}
|
|
1203
|
+
_selectNextCandidate(agents) {
|
|
1204
|
+
const now = Date.now();
|
|
1205
|
+
const candidates = [];
|
|
1206
|
+
for (const agent of agents) {
|
|
1207
|
+
const maxConcurrent = agent.concurrency?.max_concurrent ?? 1;
|
|
1208
|
+
const active = this.activeCount.get(agent.id) ?? 0;
|
|
1209
|
+
if (active >= maxConcurrent)
|
|
1210
|
+
continue;
|
|
1211
|
+
const queue = this.queues.get(agent.id) ?? [];
|
|
1212
|
+
for (let index = 0; index < queue.length; index++) {
|
|
1213
|
+
const entry = queue[index];
|
|
1214
|
+
if (entry.nextRetryAt > now)
|
|
1215
|
+
continue;
|
|
1216
|
+
// AC: @agent-dispatch-engine ac-26 — cross-agent task exclusivity:
|
|
1217
|
+
// skip candidates whose task already has an active invocation by any
|
|
1218
|
+
// agent. The entry stays queued and will be picked up after the active
|
|
1219
|
+
// invocation completes (post-invocation drain via ac-24).
|
|
1220
|
+
if (this._hasActiveInvocationForTask(entry.change.taskRef))
|
|
1221
|
+
continue;
|
|
1222
|
+
candidates.push({ agent, queue, queueIndex: index, entry });
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
if (candidates.length === 0) {
|
|
1226
|
+
return null;
|
|
1227
|
+
}
|
|
1228
|
+
candidates.sort((a, b) => this._compareSchedulerCandidates(a, b));
|
|
1229
|
+
const selected = candidates[0];
|
|
1230
|
+
this._recordContinuityDeferrals(selected, candidates);
|
|
1231
|
+
return selected;
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* Schedule a per-task coalesced drain. If a timer already exists for this
|
|
1235
|
+
* task, it is cleared and reset to the full coalescing window.
|
|
1236
|
+
*
|
|
1237
|
+
* AC: @per-task-dispatch-drain-coalescing ac-1, ac-3
|
|
1238
|
+
*/
|
|
1239
|
+
_scheduleCoalescedDrain(taskId) {
|
|
1240
|
+
// Clear any existing timer for this task (reset window)
|
|
1241
|
+
const existing = this.coalesceTimers.get(taskId);
|
|
1242
|
+
if (existing !== undefined) {
|
|
1243
|
+
clearTimeout(existing);
|
|
1244
|
+
}
|
|
1245
|
+
const timer = setTimeout(() => {
|
|
1246
|
+
this.coalesceTimers.delete(taskId);
|
|
1247
|
+
if (!this.running)
|
|
1248
|
+
return;
|
|
1249
|
+
// AC: @per-task-dispatch-drain-coalescing ac-8 — serialize drain execution
|
|
1250
|
+
this._serializedDrain().catch((err) => {
|
|
1251
|
+
console.error("[dispatch] Coalesced drain error:", err);
|
|
1252
|
+
});
|
|
1253
|
+
}, this.coalesceWindowMs);
|
|
1254
|
+
// Unref so it doesn't keep the process alive
|
|
1255
|
+
if (timer && typeof timer === "object" && "unref" in timer) {
|
|
1256
|
+
timer.unref();
|
|
1257
|
+
}
|
|
1258
|
+
this.coalesceTimers.set(taskId, timer);
|
|
1259
|
+
}
|
|
1260
|
+
/**
|
|
1261
|
+
* Serialize drain execution: if a drain is already running, mark that another
|
|
1262
|
+
* drain is pending and return. When the current drain finishes, it runs the
|
|
1263
|
+
* follow-up drain. This prevents concurrent _drainQueues() calls from
|
|
1264
|
+
* racing on queue state.
|
|
1265
|
+
*
|
|
1266
|
+
* AC: @per-task-dispatch-drain-coalescing ac-8
|
|
1267
|
+
*/
|
|
1268
|
+
async _serializedDrain() {
|
|
1269
|
+
if (this.drainInProgress) {
|
|
1270
|
+
this.drainPending = true;
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
this.drainInProgress = true;
|
|
1274
|
+
try {
|
|
1275
|
+
const agents = await this._loadAgents();
|
|
1276
|
+
await this._drainQueues(agents);
|
|
1277
|
+
}
|
|
1278
|
+
finally {
|
|
1279
|
+
this.drainInProgress = false;
|
|
1280
|
+
}
|
|
1281
|
+
// If another drain was requested while we were running, do one follow-up.
|
|
1282
|
+
if (this.drainPending) {
|
|
1283
|
+
this.drainPending = false;
|
|
1284
|
+
await this._serializedDrain();
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
1288
|
+
* Drain queues, spawning invocations up to each agent's max_concurrent limit.
|
|
1289
|
+
* AC: @agent-dispatch-engine ac-3, ac-17
|
|
1290
|
+
*/
|
|
1291
|
+
async _drainQueues(agents) {
|
|
1292
|
+
// Prevent new invocation starts during/after shutdown.
|
|
1293
|
+
if (!this.running)
|
|
1294
|
+
return;
|
|
1295
|
+
// AC: @agent-dispatch-engine ac-17 - Load current tasks once for staleness + readiness checks
|
|
1296
|
+
let currentTasks;
|
|
1297
|
+
let currentTaskStates;
|
|
1298
|
+
try {
|
|
1299
|
+
const ctx = await initContext(this.projectDir);
|
|
1300
|
+
currentTasks = await loadAllTasks(ctx);
|
|
1301
|
+
currentTaskStates = new Map(currentTasks.map((t) => [t._ulid, t.status]));
|
|
1302
|
+
}
|
|
1303
|
+
catch {
|
|
1304
|
+
// If we can't load tasks, skip staleness checks (best effort)
|
|
1305
|
+
}
|
|
1306
|
+
await this._pruneIneligibleQueueEntries(agents, currentTasks, currentTaskStates);
|
|
1307
|
+
while (this.running) {
|
|
1308
|
+
const candidate = this._selectNextCandidate(agents);
|
|
1309
|
+
if (!candidate) {
|
|
1310
|
+
break;
|
|
1311
|
+
}
|
|
1312
|
+
const [entry] = candidate.queue.splice(candidate.queueIndex, 1);
|
|
1313
|
+
this.queues.set(candidate.agent.id, candidate.queue);
|
|
1314
|
+
const spawned = await this._spawnInvocation(candidate.agent, entry);
|
|
1315
|
+
if (!spawned) {
|
|
1316
|
+
continue;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
663
1320
|
/**
|
|
664
1321
|
* Build dispatch-mode prompt guardrails to keep autonomous agents from
|
|
665
1322
|
* stopping with handoff text instead of performing required actions.
|
|
666
1323
|
*
|
|
667
1324
|
* AC: @agent-dispatch-engine ac-13, ac-14, ac-15, ac-16
|
|
668
1325
|
*/
|
|
669
|
-
_buildDispatchPrompt(agent, change) {
|
|
1326
|
+
async _buildDispatchPrompt(agent, change, workspace) {
|
|
670
1327
|
const trigger = (STATUS_TO_EVENT[change.toStatus] ?? "task.ready");
|
|
671
1328
|
const taskRef = change.taskRef;
|
|
672
1329
|
// AC: @agent-dispatch-engine ac-16 - Interpolate prompt_template variables
|
|
@@ -678,8 +1335,13 @@ export class DispatchEngine {
|
|
|
678
1335
|
};
|
|
679
1336
|
const rawTemplate = agent.prompt_template ?? `Work on task ${taskRef}`;
|
|
680
1337
|
const basePrompt = interpolateTemplate(rawTemplate, templateVars);
|
|
681
|
-
// AC: @
|
|
682
|
-
|
|
1338
|
+
// AC: @review-fix-cycle-diff ac-2 — Compute fix-cycle diff for reviewer orientation
|
|
1339
|
+
let fixCycleDiffSummary = null;
|
|
1340
|
+
if (trigger === "task.pending_review") {
|
|
1341
|
+
fixCycleDiffSummary = await getFixCycleDiffSummary(this.projectDir, taskRef, workspace.metadata.canonicalBranchHead, workspace.cwd);
|
|
1342
|
+
}
|
|
1343
|
+
const orientation = buildOrientationContext(taskRef, trigger, workspace, change.task, undefined, undefined, { fixCycleDiffSummary });
|
|
1344
|
+
const roleEntry = await buildRoleEntryContext(this.projectDir, agent.adapter ?? "claude-agent-acp", trigger, workspace.metadata);
|
|
683
1345
|
const autonomousPreamble = [
|
|
684
1346
|
"AUTONOMOUS DISPATCH MODE (no interactive user is available).",
|
|
685
1347
|
"- Do not ask for confirmation, approval, or next-step handoff.",
|
|
@@ -687,6 +1349,7 @@ export class DispatchEngine {
|
|
|
687
1349
|
"- Do not end your turn with a recommendations-only summary. Perform the next required action yourself.",
|
|
688
1350
|
"- Do not end your turn until the expected task transition is complete, or you have explicitly blocked the task with `kspec task block <task> --reason \"...\"`.",
|
|
689
1351
|
"- If you find an open PR/branch from a different task, create or switch to a dedicated branch for this task before committing to avoid PR conflation.",
|
|
1352
|
+
`- CRITICAL: Your working directory is your assigned workspace (${workspace.cwd}). Run ALL commands (tests, builds, git, kspec, etc.) from this directory. Do NOT cd to the project root or any other directory. The workspace is a full git worktree with the correct branch and project configuration.`,
|
|
690
1353
|
];
|
|
691
1354
|
const triggerSpecific = trigger === "task.pending_review"
|
|
692
1355
|
? [
|
|
@@ -701,160 +1364,432 @@ export class DispatchEngine {
|
|
|
701
1364
|
`- Perform the required commands to move ${taskRef} to the next appropriate state in this same invocation.`,
|
|
702
1365
|
"- If your workflow includes git or PR steps, execute them directly instead of deferring to a human.",
|
|
703
1366
|
];
|
|
704
|
-
return `${basePrompt}\n\n${orientation}\n\n${autonomousPreamble.join("\n")}\n\n${triggerSpecific.join("\n")}`;
|
|
1367
|
+
return `${basePrompt}\n\n${orientation}\n\n${roleEntry}\n\n${autonomousPreamble.join("\n")}\n\n${triggerSpecific.join("\n")}`;
|
|
1368
|
+
}
|
|
1369
|
+
_runKspecCommand(args) {
|
|
1370
|
+
const result = spawnSync(process.execPath, [this.kspecCliPath ?? DEFAULT_KSPEC_CLI_PATH, ...args], {
|
|
1371
|
+
cwd: this.cwd,
|
|
1372
|
+
encoding: "utf-8",
|
|
1373
|
+
stdio: "pipe",
|
|
1374
|
+
});
|
|
1375
|
+
return {
|
|
1376
|
+
status: result.status,
|
|
1377
|
+
stdout: result.stdout ?? "",
|
|
1378
|
+
stderr: result.stderr ?? "",
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
_taskCommandError(args, result) {
|
|
1382
|
+
const exitDetail = result.status === null ? "terminated by signal" : `exited with status ${result.status}`;
|
|
1383
|
+
const details = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join(" ");
|
|
1384
|
+
return new Error(`Failed to run \`kspec ${args.join(" ")}\`: ${exitDetail}${details ? `. ${details}` : ""}`);
|
|
1385
|
+
}
|
|
1386
|
+
_addTaskNote(taskRef, note) {
|
|
1387
|
+
const args = ["task", "note", taskRef, note];
|
|
1388
|
+
const result = this._runKspecCommand(args);
|
|
1389
|
+
if (result.status !== 0) {
|
|
1390
|
+
console.warn(`[dispatch] Failed to add task note for ${taskRef}: ${this._taskCommandError(args, result).message}`);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
_blockTask(taskRef, reason) {
|
|
1394
|
+
const args = ["task", "block", taskRef, "--reason", reason];
|
|
1395
|
+
const result = this._runKspecCommand(args);
|
|
1396
|
+
if (result.status !== 0) {
|
|
1397
|
+
throw this._taskCommandError(args, result);
|
|
1398
|
+
}
|
|
705
1399
|
}
|
|
706
1400
|
/**
|
|
707
1401
|
* Spawn a single invocation for a queue entry.
|
|
708
1402
|
* Returns true if an invocation was actually started, false if skipped.
|
|
709
1403
|
* AC: @agent-dispatch-engine ac-9, ac-10, ac-11, ac-12
|
|
710
1404
|
*/
|
|
711
|
-
_spawnInvocation(agent, entry) {
|
|
1405
|
+
async _spawnInvocation(agent, entry) {
|
|
1406
|
+
// Bail out during shutdown — don't provision workspaces or add to
|
|
1407
|
+
// runningInvocations for invocations that will never complete.
|
|
1408
|
+
if (!this.running)
|
|
1409
|
+
return false;
|
|
712
1410
|
const agentId = agent.id;
|
|
713
|
-
|
|
714
|
-
this.
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
const
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
1411
|
+
const inFlightKey = `${agentId}:${entry.change.taskRef}`;
|
|
1412
|
+
this.inFlightTaskKeys.add(inFlightKey);
|
|
1413
|
+
let invocationRegistered = false;
|
|
1414
|
+
let workspace;
|
|
1415
|
+
const role = entry.change.toStatus === "pending_review" ? "reviewer" : "worker";
|
|
1416
|
+
try {
|
|
1417
|
+
try {
|
|
1418
|
+
workspace = await provisionDispatchWorkspace({
|
|
1419
|
+
projectDir: this.projectDir,
|
|
1420
|
+
taskRef: entry.change.taskRef,
|
|
1421
|
+
role,
|
|
1422
|
+
cleanupState: {
|
|
1423
|
+
taskStatus: entry.change.task?.status ?? entry.change.toStatus,
|
|
1424
|
+
},
|
|
1425
|
+
task: entry.change.task
|
|
1426
|
+
? {
|
|
1427
|
+
title: entry.change.task.title,
|
|
1428
|
+
slugs: entry.change.task.slugs,
|
|
1429
|
+
}
|
|
1430
|
+
: undefined,
|
|
1431
|
+
// AC: @adopt-existing-task-branch-lineage ac-1, ac-2, ac-4
|
|
1432
|
+
// Pass submission linkage so provisioning can adopt an existing branch
|
|
1433
|
+
// when no workspace record exists for review/fix-cycle tasks.
|
|
1434
|
+
submissionLinkage: entry.change.task?.submission_linkage ?? undefined,
|
|
1435
|
+
taskStatus: entry.change.task?.status ?? entry.change.toStatus,
|
|
1436
|
+
});
|
|
730
1437
|
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
1438
|
+
catch (err) {
|
|
1439
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1440
|
+
const guidance = err instanceof DispatchWorkspaceError ? err.suggestion : "Inspect dispatch workspace configuration and git worktree state.";
|
|
1441
|
+
console.error(`[dispatch] Failed to provision workspace for ${entry.change.taskRef}: ${message}`);
|
|
1442
|
+
this._addTaskNote(entry.change.taskRef, `[DISPATCH-WORKSPACE] ${message} Suggested action: ${guidance}`);
|
|
1443
|
+
this._blockTask(entry.change.taskRef, `Dispatch workspace provisioning failed: ${message}. Suggested action: ${guidance}`);
|
|
1444
|
+
return false;
|
|
1445
|
+
}
|
|
1446
|
+
const dispatchEnv = {
|
|
1447
|
+
KSPEC_DISPATCH_BASE_BRANCH: workspace.metadata.baseBranch,
|
|
1448
|
+
KSPEC_DISPATCH_MERGE_TARGET: workspace.metadata.mergeTargetBranch,
|
|
1449
|
+
KSPEC_DISPATCH_CANONICAL_BRANCH: workspace.metadata.canonicalBranch,
|
|
1450
|
+
KSPEC_DISPATCH_WORKTREE_ROOT: workspace.metadata.worktreeRoot,
|
|
1451
|
+
KSPEC_DISPATCH_WORKSPACE_FILE: workspace.metadataPath,
|
|
1452
|
+
};
|
|
1453
|
+
try {
|
|
1454
|
+
const bootstrap = await ensureWorkspaceBootstrap({
|
|
1455
|
+
projectDir: this.projectDir,
|
|
1456
|
+
workspaceDir: workspace.cwd,
|
|
1457
|
+
metadataPath: workspace.metadataPath,
|
|
1458
|
+
metadata: workspace.metadata,
|
|
1459
|
+
role,
|
|
1460
|
+
agent,
|
|
1461
|
+
env: dispatchEnv,
|
|
1462
|
+
});
|
|
1463
|
+
workspace = {
|
|
1464
|
+
...workspace,
|
|
1465
|
+
metadata: bootstrap.metadata,
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
catch (err) {
|
|
1469
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1470
|
+
const guidance = err instanceof DispatchBootstrapError
|
|
1471
|
+
? err.suggestion
|
|
1472
|
+
: "Inspect dispatch bootstrap configuration, dependency prerequisites, and workspace health.";
|
|
1473
|
+
console.error(`[dispatch] Failed to bootstrap workspace for ${entry.change.taskRef}: ${message}`);
|
|
1474
|
+
if (this.kspecCliPath) {
|
|
1475
|
+
spawnSync(process.execPath, [
|
|
1476
|
+
this.kspecCliPath,
|
|
1477
|
+
"task", "note", entry.change.taskRef,
|
|
1478
|
+
`[DISPATCH-BOOTSTRAP] ${message} Suggested action: ${guidance}`,
|
|
1479
|
+
], { cwd: this.cwd });
|
|
1480
|
+
spawnSync(process.execPath, [
|
|
1481
|
+
this.kspecCliPath,
|
|
1482
|
+
"task", "block", entry.change.taskRef,
|
|
1483
|
+
"--reason", `Dispatch bootstrap failed: ${message}`,
|
|
1484
|
+
], { cwd: this.cwd });
|
|
765
1485
|
}
|
|
766
|
-
|
|
767
|
-
// Emit an empty sentinel so watch renderers can end the current line
|
|
768
|
-
// without needing to infer boundaries from prose punctuation.
|
|
769
|
-
this.onTextChunk(preSessionId, agentId, taskId, "");
|
|
1486
|
+
return false;
|
|
770
1487
|
}
|
|
771
|
-
|
|
772
|
-
const options = {
|
|
773
|
-
agent,
|
|
774
|
-
specDir: this.specDir,
|
|
775
|
-
cwd: this.cwd,
|
|
776
|
-
taskRef: entry.change.taskRef,
|
|
777
|
-
prompt: this._buildDispatchPrompt(agent, entry.change),
|
|
778
|
-
trigger: (STATUS_TO_EVENT[entry.change.toStatus] ?? "task.ready"),
|
|
779
|
-
kspecCliPath: this.kspecCliPath,
|
|
780
|
-
abortSignal: abortController.signal,
|
|
781
|
-
sessionId: preSessionId,
|
|
782
|
-
onUpdate,
|
|
783
|
-
};
|
|
784
|
-
// AC: @agent-dispatch-engine ac-12 - Wrap invocation in shadow mutex
|
|
785
|
-
const invocationPromise = this.shadowMutex
|
|
786
|
-
.runExclusive(async () => {
|
|
787
|
-
// AC: @agent-dispatch-engine ac-9 - Retry on transient errors
|
|
1488
|
+
let prompt;
|
|
788
1489
|
try {
|
|
789
|
-
await
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
1490
|
+
prompt = await this._buildDispatchPrompt(agent, entry.change, workspace);
|
|
1491
|
+
}
|
|
1492
|
+
catch (err) {
|
|
1493
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1494
|
+
const guidance = err instanceof DispatchPromptError
|
|
1495
|
+
? err.suggestion
|
|
1496
|
+
: "Inspect dispatch role-entry configuration and workspace metadata.";
|
|
1497
|
+
console.error(`[dispatch] Failed to build prompt for ${entry.change.taskRef}: ${message}`);
|
|
1498
|
+
if (this.kspecCliPath) {
|
|
1499
|
+
spawnSync(process.execPath, [
|
|
1500
|
+
this.kspecCliPath,
|
|
1501
|
+
"task", "note", entry.change.taskRef,
|
|
1502
|
+
`[DISPATCH-PROMPT] ${message} Suggested action: ${guidance}`,
|
|
1503
|
+
], { cwd: this.cwd });
|
|
1504
|
+
}
|
|
1505
|
+
return false;
|
|
1506
|
+
}
|
|
1507
|
+
// Increment active count
|
|
1508
|
+
this.activeCount.set(agentId, (this.activeCount.get(agentId) ?? 0) + 1);
|
|
1509
|
+
// AC: @agent-dispatch-engine ac-10 - Check adapter resolvability before spawn
|
|
1510
|
+
const adapterId = agent.adapter ?? "claude-agent-acp";
|
|
1511
|
+
const adapter = getAdapter(adapterId);
|
|
1512
|
+
if (!adapter) {
|
|
1513
|
+
console.error(`[dispatch] Cannot resolve adapter "${adapterId}" for agent "${agentId}". Skipping invocation.`);
|
|
1514
|
+
// Decrement active count since we're not actually running
|
|
1515
|
+
const currentActive = this.activeCount.get(agentId) ?? 1;
|
|
1516
|
+
this.activeCount.set(agentId, Math.max(0, currentActive - 1));
|
|
1517
|
+
// AC: @agent-dispatch-engine ac-10 - Add task note for unresolvable adapter
|
|
1518
|
+
if (this.kspecCliPath) {
|
|
1519
|
+
spawnSync(process.execPath, [
|
|
1520
|
+
this.kspecCliPath,
|
|
1521
|
+
"task", "note", entry.change.taskRef,
|
|
1522
|
+
`[AGENT-SKIP] Cannot resolve adapter "${adapterId}" for agent "${agentId}". Invocation skipped.`,
|
|
1523
|
+
], { cwd: this.cwd });
|
|
1524
|
+
}
|
|
1525
|
+
return false;
|
|
1526
|
+
}
|
|
1527
|
+
// AC: @agent-dispatch-engine ac-11 - Create abort controller for graceful cancellation
|
|
1528
|
+
const abortController = new AbortController();
|
|
1529
|
+
this.invocationAbortControllers.add(abortController);
|
|
1530
|
+
// AC: @cli-agent-commands ac-6 - Pre-assign session ID for status tracking
|
|
1531
|
+
const preSessionId = ulid();
|
|
1532
|
+
const invocationId = ulid();
|
|
1533
|
+
const trackingRecord = {
|
|
1534
|
+
invocationId,
|
|
1535
|
+
sessionId: preSessionId,
|
|
1536
|
+
agentId,
|
|
1537
|
+
agentName: agent.name,
|
|
1538
|
+
taskRef: entry.change.taskRef,
|
|
1539
|
+
role,
|
|
1540
|
+
startedAtMs: Date.now(),
|
|
1541
|
+
};
|
|
1542
|
+
this.activeInvocationDetails.set(invocationId, trackingRecord);
|
|
1543
|
+
this.recentTaskAffinityRef = entry.change.taskRef;
|
|
1544
|
+
let startedEventEmitted = false;
|
|
1545
|
+
const emitStartedEvent = () => {
|
|
1546
|
+
if (startedEventEmitted)
|
|
1547
|
+
return;
|
|
1548
|
+
startedEventEmitted = true;
|
|
793
1549
|
this.onInvocationEvent?.({
|
|
794
|
-
type: "
|
|
1550
|
+
type: "started",
|
|
795
1551
|
session_id: preSessionId,
|
|
796
1552
|
agent_id: agentId,
|
|
797
1553
|
task_id: entry.change.taskRef,
|
|
798
|
-
|
|
1554
|
+
task_title: entry.change.task?.title ?? null,
|
|
1555
|
+
status: "started",
|
|
799
1556
|
timestamp: Date.now(),
|
|
800
1557
|
});
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
1558
|
+
};
|
|
1559
|
+
// AC: @session-event-broadcast ac-newline-streaming, ac-boundary-flush, ac-per-session-state
|
|
1560
|
+
// AC: @cli-agent-commands ac-13, @daemon-agent-dispatch ac-8 - stream session events to watchers
|
|
1561
|
+
const taskId = entry.change.taskRef ?? null;
|
|
1562
|
+
const taskTitle = entry.change.task?.title ?? null;
|
|
1563
|
+
const sessionCtx = {
|
|
1564
|
+
sessionId: preSessionId,
|
|
1565
|
+
agentId,
|
|
1566
|
+
taskId,
|
|
1567
|
+
taskTitle,
|
|
1568
|
+
};
|
|
1569
|
+
const onUpdate = this.onSessionEvent
|
|
1570
|
+
? (update) => {
|
|
1571
|
+
emitStartedEvent();
|
|
1572
|
+
this.accumulator.handleUpdate(sessionCtx, update, this.onSessionEvent);
|
|
1573
|
+
}
|
|
1574
|
+
: undefined;
|
|
1575
|
+
const options = {
|
|
1576
|
+
agent,
|
|
1577
|
+
specDir: this.specDir,
|
|
1578
|
+
sessionsDir: path.join(this.projectDir, ".kspec-sessions"),
|
|
1579
|
+
cwd: workspace.cwd,
|
|
1580
|
+
taskRef: entry.change.taskRef,
|
|
1581
|
+
prompt,
|
|
1582
|
+
trigger: (STATUS_TO_EVENT[entry.change.toStatus] ?? "task.ready"),
|
|
1583
|
+
kspecCliPath: this.kspecCliPath,
|
|
1584
|
+
abortSignal: abortController.signal,
|
|
1585
|
+
sessionId: preSessionId,
|
|
1586
|
+
mutationLockFile: getDispatchShadowMutationLockPath(this.projectDir),
|
|
1587
|
+
env: {
|
|
1588
|
+
KSPEC_DISPATCH_BASE_BRANCH: workspace.metadata.baseBranch,
|
|
1589
|
+
KSPEC_DISPATCH_MERGE_TARGET: workspace.metadata.mergeTargetBranch,
|
|
1590
|
+
KSPEC_DISPATCH_CANONICAL_BRANCH: workspace.metadata.canonicalBranch,
|
|
1591
|
+
KSPEC_DISPATCH_CANONICAL_HEAD: workspace.metadata.canonicalBranchHead,
|
|
1592
|
+
KSPEC_DISPATCH_INTEGRATION_TARGET: workspace.metadata.integrationTargetBranch,
|
|
1593
|
+
KSPEC_DISPATCH_INTEGRATION_COMMIT: workspace.metadata.integrationTargetCommit,
|
|
1594
|
+
KSPEC_DISPATCH_PUBLICATION_MODE: workspace.metadata.publicationMode,
|
|
1595
|
+
KSPEC_DISPATCH_INTEGRATION_STATE: workspace.metadata.integrationState,
|
|
1596
|
+
KSPEC_DISPATCH_INTEGRATION_OUTCOME: workspace.metadata.integrationOutcome,
|
|
1597
|
+
KSPEC_DISPATCH_WORKTREE_ROOT: workspace.metadata.worktreeRoot,
|
|
1598
|
+
KSPEC_DISPATCH_WORKSPACE_FILE: workspace.metadataPath,
|
|
1599
|
+
KSPEC_DISPATCH_WORKSPACE_ID: workspace.metadata.workspaceId,
|
|
1600
|
+
KSPEC_DISPATCH_BOOTSTRAP_STATUS: workspace.metadata.bootstrap.status,
|
|
1601
|
+
KSPEC_DISPATCH_BOOTSTRAP_LAST_ROLE: workspace.metadata.bootstrap.lastRole ?? "",
|
|
1602
|
+
},
|
|
1603
|
+
onUpdate,
|
|
1604
|
+
// AC: @session-summary-cache ac-live-counter — increment cache counter on each event append
|
|
1605
|
+
onEventAppended: (sid) => {
|
|
1606
|
+
const sessionsDir = path.join(this.projectDir, ".kspec-sessions");
|
|
1607
|
+
const cache = getSessionCache(sessionsDir);
|
|
1608
|
+
cache.incrementEventCount(sid);
|
|
1609
|
+
},
|
|
1610
|
+
};
|
|
1611
|
+
let resolveInvocationStarted;
|
|
1612
|
+
const invocationStarted = new Promise((resolve) => {
|
|
1613
|
+
resolveInvocationStarted = resolve;
|
|
1614
|
+
});
|
|
1615
|
+
let invocationStartedResolved = false;
|
|
1616
|
+
const markInvocationStarted = () => {
|
|
1617
|
+
if (invocationStartedResolved)
|
|
1618
|
+
return;
|
|
1619
|
+
invocationStartedResolved = true;
|
|
1620
|
+
resolveInvocationStarted();
|
|
1621
|
+
};
|
|
1622
|
+
let terminalEvent = null;
|
|
1623
|
+
let releasedInFlightKey = false;
|
|
1624
|
+
const markActivePromise = this.shadowMutex.runExclusive(async () => {
|
|
1625
|
+
try {
|
|
1626
|
+
const activeWorkspace = await markDispatchWorkspaceActive({
|
|
1627
|
+
projectDir: this.projectDir,
|
|
1628
|
+
taskRef: entry.change.taskRef,
|
|
1629
|
+
role: entry.change.toStatus === "pending_review" ? "reviewer" : "worker",
|
|
1630
|
+
});
|
|
1631
|
+
if (activeWorkspace) {
|
|
1632
|
+
workspace = activeWorkspace;
|
|
1633
|
+
options.env = {
|
|
1634
|
+
...options.env,
|
|
1635
|
+
KSPEC_DISPATCH_WORKSPACE_FILE: activeWorkspace.metadataPath,
|
|
1636
|
+
KSPEC_DISPATCH_WORKSPACE_ID: activeWorkspace.metadata.workspaceId,
|
|
1637
|
+
};
|
|
1638
|
+
}
|
|
821
1639
|
}
|
|
822
|
-
|
|
823
|
-
console.error(`[dispatch]
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1640
|
+
catch (err) {
|
|
1641
|
+
console.error(`[dispatch] Failed to mark workspace active for ${entry.change.taskRef}:`, err);
|
|
1642
|
+
}
|
|
1643
|
+
});
|
|
1644
|
+
const invocationPromise = Promise.resolve()
|
|
1645
|
+
.then(async () => {
|
|
1646
|
+
// AC: @agent-dispatch-engine ac-9 - Retry on transient errors
|
|
1647
|
+
try {
|
|
1648
|
+
markInvocationStarted();
|
|
1649
|
+
emitStartedEvent();
|
|
1650
|
+
await markActivePromise;
|
|
1651
|
+
await runInvocation(options);
|
|
1652
|
+
// Reset retry count on success
|
|
1653
|
+
entry.retryCount = 0;
|
|
1654
|
+
entry.starvationDeferrals = 0;
|
|
1655
|
+
this.recentTaskAffinityRef = entry.change.taskRef;
|
|
1656
|
+
terminalEvent = {
|
|
1657
|
+
type: "completed",
|
|
827
1658
|
session_id: preSessionId,
|
|
828
1659
|
agent_id: agentId,
|
|
829
1660
|
task_id: entry.change.taskRef,
|
|
830
|
-
|
|
1661
|
+
task_title: entry.change.task?.title ?? null,
|
|
1662
|
+
status: "completed",
|
|
831
1663
|
timestamp: Date.now(),
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
catch (err) {
|
|
1667
|
+
markInvocationStarted();
|
|
1668
|
+
const retryLimit = agent.budget?.max_retries ?? 3;
|
|
1669
|
+
if (entry.retryCount < retryLimit) {
|
|
1670
|
+
entry.retryCount++;
|
|
1671
|
+
const backoffMs = Math.min(1000 * Math.pow(2, entry.retryCount - 1), 30_000);
|
|
1672
|
+
entry.nextRetryAt = Date.now() + backoffMs;
|
|
1673
|
+
console.warn(`[dispatch] Invocation for agent "${agentId}" failed (attempt ${entry.retryCount}/${retryLimit}), retrying in ${backoffMs}ms`, err);
|
|
1674
|
+
// Re-enqueue for retry while preserving status precedence ordering.
|
|
1675
|
+
const queue = this.queues.get(agentId) ?? [];
|
|
1676
|
+
this._insertQueueEntry(queue, entry);
|
|
1677
|
+
this.queues.set(agentId, queue);
|
|
1678
|
+
// AC: @agent-dispatch-engine ac-9, ac-27 - Schedule wake-up to drain retry
|
|
1679
|
+
// All drains go through _serializedDrain() to prevent concurrent races.
|
|
1680
|
+
setTimeout(() => {
|
|
1681
|
+
if (this.running) {
|
|
1682
|
+
this._serializedDrain()
|
|
1683
|
+
.catch(() => { });
|
|
1684
|
+
}
|
|
1685
|
+
}, backoffMs);
|
|
1686
|
+
}
|
|
1687
|
+
else {
|
|
1688
|
+
console.error(`[dispatch] Agent "${agentId}" exceeded retry limit. Dropping invocation.`, err);
|
|
1689
|
+
terminalEvent = {
|
|
1690
|
+
type: "failed",
|
|
1691
|
+
session_id: preSessionId,
|
|
1692
|
+
agent_id: agentId,
|
|
1693
|
+
task_id: entry.change.taskRef,
|
|
1694
|
+
task_title: entry.change.task?.title ?? null,
|
|
1695
|
+
status: "failed",
|
|
1696
|
+
timestamp: Date.now(),
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
// AC: @session-summary-cache ac-live-counter — discard live counter after session closes
|
|
1701
|
+
// and invalidate cache entry so next list picks up persisted stats
|
|
1702
|
+
{
|
|
1703
|
+
const sessionsDir = path.join(this.projectDir, ".kspec-sessions");
|
|
1704
|
+
const cache = getSessionCache(sessionsDir);
|
|
1705
|
+
cache.discardLiveCounter(preSessionId);
|
|
1706
|
+
cache.invalidate(preSessionId);
|
|
1707
|
+
}
|
|
1708
|
+
try {
|
|
1709
|
+
await this.shadowMutex.runExclusive(async () => {
|
|
1710
|
+
await markDispatchWorkspaceIdle({
|
|
1711
|
+
projectDir: this.projectDir,
|
|
1712
|
+
taskRef: entry.change.taskRef,
|
|
1713
|
+
taskStatus: entry.change.toStatus,
|
|
1714
|
+
});
|
|
832
1715
|
});
|
|
833
1716
|
}
|
|
1717
|
+
catch (err) {
|
|
1718
|
+
console.error(`[dispatch] Failed to mark workspace idle for ${entry.change.taskRef}:`, err);
|
|
1719
|
+
}
|
|
1720
|
+
// Clean up active tracking before queue drain runs so completed
|
|
1721
|
+
// invocations do not linger in the active fleet snapshot.
|
|
1722
|
+
const currentActive = this.activeCount.get(agentId) ?? 1;
|
|
1723
|
+
this.activeCount.set(agentId, Math.max(0, currentActive - 1));
|
|
1724
|
+
this.activeInvocationDetails.delete(invocationId);
|
|
1725
|
+
if (entry.change.toStatus === "pending_review") {
|
|
1726
|
+
try {
|
|
1727
|
+
await this.shadowMutex.runExclusive(async () => {
|
|
1728
|
+
await cleanupReviewerDispatchWorkspace(this.projectDir, entry.change.taskRef, entry.change.task
|
|
1729
|
+
? {
|
|
1730
|
+
title: entry.change.task.title,
|
|
1731
|
+
slugs: entry.change.task.slugs,
|
|
1732
|
+
}
|
|
1733
|
+
: undefined);
|
|
1734
|
+
});
|
|
1735
|
+
}
|
|
1736
|
+
catch (cleanupErr) {
|
|
1737
|
+
const cleanupMessage = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr);
|
|
1738
|
+
console.warn(`[dispatch] Failed to clean reviewer snapshot for ${entry.change.taskRef}: ${cleanupMessage}`);
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
// Flush any remaining buffered text before emitting terminal event
|
|
1742
|
+
if (this.onSessionEvent) {
|
|
1743
|
+
this.accumulator.endSession(sessionCtx, this.onSessionEvent);
|
|
1744
|
+
}
|
|
1745
|
+
if (terminalEvent) {
|
|
1746
|
+
this.onInvocationEvent?.(terminalEvent);
|
|
1747
|
+
}
|
|
1748
|
+
})
|
|
1749
|
+
.then(async () => {
|
|
1750
|
+
if (!this.running)
|
|
1751
|
+
return;
|
|
1752
|
+
// Release the in-flight marker before re-evaluating tasks from disk so
|
|
1753
|
+
// follow-up reviewer/fix-cycle work for the same task can be requeued
|
|
1754
|
+
// immediately after the prior invocation completes.
|
|
1755
|
+
this.inFlightTaskKeys.delete(inFlightKey);
|
|
1756
|
+
releasedInFlightKey = true;
|
|
1757
|
+
// AC: @agent-dispatch-engine ac-23, ac-24
|
|
1758
|
+
// Re-evaluate all tasks from disk so the drain loop sees tasks that
|
|
1759
|
+
// reached a dispatchable state during the prior invocation (e.g.
|
|
1760
|
+
// pending_review tasks submitted by a worker).
|
|
1761
|
+
try {
|
|
1762
|
+
await this._evaluateAllTasks({ skipIfActive: true });
|
|
1763
|
+
}
|
|
1764
|
+
catch (err) {
|
|
1765
|
+
// AC: @agent-dispatch-engine ac-25
|
|
1766
|
+
console.warn("[dispatch] Post-invocation re-evaluation failed, proceeding with existing queue:", err);
|
|
1767
|
+
}
|
|
1768
|
+
// AC: @agent-dispatch-engine ac-27 — all drains go through _serializedDrain()
|
|
1769
|
+
try {
|
|
1770
|
+
await this._serializedDrain();
|
|
1771
|
+
}
|
|
1772
|
+
catch {
|
|
1773
|
+
// Best effort drain
|
|
1774
|
+
}
|
|
1775
|
+
})
|
|
1776
|
+
.finally(() => {
|
|
1777
|
+
if (!releasedInFlightKey) {
|
|
1778
|
+
this.inFlightTaskKeys.delete(inFlightKey);
|
|
1779
|
+
}
|
|
1780
|
+
this.runningInvocations.delete(invocationPromise);
|
|
1781
|
+
this.invocationAbortControllers.delete(abortController);
|
|
1782
|
+
});
|
|
1783
|
+
invocationRegistered = true;
|
|
1784
|
+
this.runningInvocations.add(invocationPromise);
|
|
1785
|
+
await invocationStarted;
|
|
1786
|
+
return true;
|
|
1787
|
+
}
|
|
1788
|
+
finally {
|
|
1789
|
+
if (!invocationRegistered) {
|
|
1790
|
+
this.inFlightTaskKeys.delete(inFlightKey);
|
|
834
1791
|
}
|
|
835
|
-
}
|
|
836
|
-
.then(async () => {
|
|
837
|
-
// Decrement active count and drain again
|
|
838
|
-
const currentActive = this.activeCount.get(agentId) ?? 1;
|
|
839
|
-
this.activeCount.set(agentId, Math.max(0, currentActive - 1));
|
|
840
|
-
if (!this.running)
|
|
841
|
-
return;
|
|
842
|
-
// Try to drain more items
|
|
843
|
-
try {
|
|
844
|
-
const agents = await this._loadAgents();
|
|
845
|
-
await this._drainQueues(agents);
|
|
846
|
-
}
|
|
847
|
-
catch {
|
|
848
|
-
// Best effort
|
|
849
|
-
}
|
|
850
|
-
})
|
|
851
|
-
.finally(() => {
|
|
852
|
-
this.runningInvocations.delete(invocationPromise);
|
|
853
|
-
this.invocationAbortControllers.delete(abortController);
|
|
854
|
-
this.activeInvocationDetails.delete(invocationId);
|
|
855
|
-
});
|
|
856
|
-
this.runningInvocations.add(invocationPromise);
|
|
857
|
-
return true;
|
|
1792
|
+
}
|
|
858
1793
|
}
|
|
859
1794
|
}
|
|
860
1795
|
//# sourceMappingURL=dispatch.js.map
|