@kynetic-ai/spec 0.10.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 +150 -10
- package/dist/agent-runtime/dispatch.d.ts.map +1 -1
- package/dist/agent-runtime/dispatch.js +1248 -244
- 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 +172 -60
- 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/meta.d.ts.map +1 -1
- package/dist/cli/commands/meta.js +10 -1
- package/dist/cli/commands/meta.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 +107 -23
- package/dist/daemon/routes/aggregation.ts +184 -0
- package/dist/daemon/routes/inbox.ts +5 -0
- package/dist/daemon/routes/items.ts +167 -0
- package/dist/daemon/routes/meta.ts +141 -1
- package/dist/daemon/routes/plans.ts +147 -0
- 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 +581 -0
- package/dist/daemon/routes/tasks.ts +257 -2
- package/dist/daemon/routes/triage.ts +40 -1
- package/dist/daemon/routes/validation.ts +1 -1
- package/dist/daemon/server.ts +165 -50
- 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/alignment.d.ts.map +1 -1
- package/dist/parser/alignment.js +4 -2
- package/dist/parser/alignment.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 +663 -116
- package/dist/schema/meta.d.ts.map +1 -1
- package/dist/schema/meta.js +28 -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 +115 -71
- package/dist/sessions/store.d.ts.map +1 -1
- package/dist/sessions/store.js +357 -182
- 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/B25nWFyA.js +5 -0
- package/dist/web-ui/_app/immutable/chunks/B2bcA_Q_.js +1 -0
- 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/B8tYZKAE.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/BFGAyJjD.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/BG0850zf.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/BG8eSzAd.js +1 -0
- 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/{D1ArdqNb.js → Bp5pFYXL.js} +1 -1
- package/dist/web-ui/_app/immutable/chunks/BsJFsuAT.js +1 -0
- 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/C0w6WDm5.js +1 -0
- 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/CWUQwB9H.js +1 -0
- 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/CaAJD3dl.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/{i-XnOIX0.js → ChB5iyEL.js} +1 -1
- package/dist/web-ui/_app/immutable/chunks/ChQD-6N8.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/{BCkp8Hs8.js → CqbsoCwA.js} +1 -1
- package/dist/web-ui/_app/immutable/chunks/DCeJW50p.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/DJtZNgcs.js +1 -0
- 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/DW_subyT.js +2 -0
- 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/Dg_zDpDS.js +1 -0
- 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/DqT6OH_u.js +2 -0
- 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/HNjs76Zz.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/HVMjDi4_.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/P0A_fJvS.js +1 -0
- 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/Xvwhx_F1.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/Yyz1XMQA.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/dh5HeqUr.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/fZMteyca.js +62 -0
- package/dist/web-ui/_app/immutable/chunks/{D28BF5MJ.js → gPrj-hqC.js} +1 -1
- package/dist/web-ui/_app/immutable/chunks/htcWMiYN.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/oTsvd9y4.js +1 -0
- 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/y4GeEH6k.js +1 -0
- 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 -11
- package/package.json +14 -7
- 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.BxCxvrZR.css +0 -1
- package/dist/web-ui/_app/immutable/chunks/B-CZR0q8.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/B1IR5Su5.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/B_Cvvtc4.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/BtFaGGII.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/Bu8JVsCH.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/C87u-CNA.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/CrFkBTYp.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/D6RtLpzL.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/D7FHSgx2.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/DBXrsxZQ.js +0 -2
- package/dist/web-ui/_app/immutable/chunks/Da_hHMuA.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/Do6LchSF.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/DoNPtcAw.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/DtUbXRZz.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/DyFPRlLl.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/DzAP8lRM.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/DzVXElzN.js +0 -2
- package/dist/web-ui/_app/immutable/chunks/aoPBFken.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/laxtrUO3.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/q1nIWgqB.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/sTLbk5Nm.js +0 -1
- package/dist/web-ui/_app/immutable/chunks/vwKgQu5P.js +0 -5
- package/dist/web-ui/_app/immutable/entry/app.BCwMcqnT.js +0 -2
- package/dist/web-ui/_app/immutable/entry/start.wKCQH-tt.js +0 -1
- package/dist/web-ui/_app/immutable/nodes/0.CjGVMG74.js +0 -1
- package/dist/web-ui/_app/immutable/nodes/1.B6_AIPan.js +0 -1
- package/dist/web-ui/_app/immutable/nodes/2.q4oCS7Ws.js +0 -1
- package/dist/web-ui/_app/immutable/nodes/3.rTKZf9o2.js +0 -1
- package/dist/web-ui/_app/immutable/nodes/4.DVIDRu1d.js +0 -1
- package/dist/web-ui/_app/immutable/nodes/5.8PtPXIOd.js +0 -1
- package/dist/web-ui/_app/immutable/nodes/6.ZZrTemy_.js +0 -1
- package/dist/web-ui/_app/immutable/nodes/7.IP-gxCxi.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.
|
|
@@ -172,9 +418,14 @@ export class DispatchEngine {
|
|
|
172
418
|
specDir;
|
|
173
419
|
cwd;
|
|
174
420
|
dedupWindowMs;
|
|
421
|
+
reconcileIntervalMs;
|
|
422
|
+
/** AC: @per-task-dispatch-drain-coalescing ac-4 */
|
|
423
|
+
coalesceWindowMs;
|
|
175
424
|
kspecCliPath;
|
|
176
425
|
onInvocationEvent;
|
|
177
|
-
|
|
426
|
+
onSessionEvent;
|
|
427
|
+
/** Per-session text accumulator for newline-boundary streaming. */
|
|
428
|
+
accumulator = new SessionEventAccumulator();
|
|
178
429
|
/** Queue of pending dispatch entries, per agent id */
|
|
179
430
|
queues = new Map();
|
|
180
431
|
/** Count of active (running) invocations per agent id */
|
|
@@ -193,16 +444,35 @@ export class DispatchEngine {
|
|
|
193
444
|
invocationAbortControllers = new Set();
|
|
194
445
|
/** Per-invocation tracking records for status display */
|
|
195
446
|
activeInvocationDetails = new Map();
|
|
447
|
+
/** Task refs currently between queue removal and active tracking registration */
|
|
448
|
+
inFlightTaskKeys = new Set();
|
|
196
449
|
/** Monotonic enqueue sequence for deterministic queue ordering */
|
|
197
450
|
nextQueueSequence = 0;
|
|
451
|
+
/** Last task selected/completed, used as continuity affinity signal. */
|
|
452
|
+
recentTaskAffinityRef = null;
|
|
453
|
+
/** Timer handle for periodic reconciliation. AC: @agent-dispatch-engine ac-20 */
|
|
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;
|
|
198
463
|
constructor(options) {
|
|
199
464
|
this.projectDir = options.projectDir;
|
|
200
465
|
this.specDir = options.specDir ?? path.join(options.projectDir, ".kspec");
|
|
201
466
|
this.cwd = options.cwd ?? options.projectDir;
|
|
202
467
|
this.dedupWindowMs = options.dedupWindowMs ?? 2000;
|
|
468
|
+
this.reconcileIntervalMs = (options.reconcileIntervalMs === null || options.reconcileIntervalMs === 0)
|
|
469
|
+
? 0
|
|
470
|
+
: (options.reconcileIntervalMs ?? 60_000);
|
|
471
|
+
// AC: @per-task-dispatch-drain-coalescing ac-4
|
|
472
|
+
this.coalesceWindowMs = options.coalesceWindowMs ?? 5000;
|
|
203
473
|
this.kspecCliPath = options.kspecCliPath;
|
|
204
474
|
this.onInvocationEvent = options.onInvocationEvent;
|
|
205
|
-
this.
|
|
475
|
+
this.onSessionEvent = options.onSessionEvent;
|
|
206
476
|
}
|
|
207
477
|
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
208
478
|
/**
|
|
@@ -213,8 +483,36 @@ export class DispatchEngine {
|
|
|
213
483
|
*/
|
|
214
484
|
async start() {
|
|
215
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
|
+
});
|
|
216
500
|
// AC: @agent-dispatch-engine ac-8 - Bootstrap: evaluate existing task states
|
|
217
501
|
await this._bootstrap();
|
|
502
|
+
// AC: @agent-dispatch-engine ac-19, ac-20 - Start periodic reconciliation
|
|
503
|
+
if (this.reconcileIntervalMs > 0) {
|
|
504
|
+
this.reconcileTimer = setInterval(() => {
|
|
505
|
+
if (this.running) {
|
|
506
|
+
const p = this._reconcile().catch((err) => {
|
|
507
|
+
console.error("[dispatch] Reconciliation error:", err);
|
|
508
|
+
}).finally(() => {
|
|
509
|
+
this.inFlightReconciles.delete(p);
|
|
510
|
+
});
|
|
511
|
+
this.inFlightReconciles.add(p);
|
|
512
|
+
}
|
|
513
|
+
}, this.reconcileIntervalMs);
|
|
514
|
+
this.reconcileTimer.unref();
|
|
515
|
+
}
|
|
218
516
|
}
|
|
219
517
|
/**
|
|
220
518
|
* Handle a task state change event from any source (file watcher or API).
|
|
@@ -229,21 +527,54 @@ export class DispatchEngine {
|
|
|
229
527
|
return;
|
|
230
528
|
}
|
|
231
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
|
+
}
|
|
232
552
|
// AC: @agent-dispatch-engine ac-1 - Match against dispatch rules
|
|
233
553
|
const agents = await this._loadAgents();
|
|
234
554
|
const eventType = STATUS_TO_EVENT[change.toStatus];
|
|
235
555
|
if (!eventType)
|
|
236
556
|
return;
|
|
237
|
-
// Load
|
|
557
|
+
// Load all tasks for filter evaluation (needed for dependency checks)
|
|
558
|
+
let allTasks;
|
|
238
559
|
let taskData = change.task;
|
|
239
560
|
if (!taskData && change.taskId) {
|
|
240
561
|
try {
|
|
241
562
|
const ctx = await initContext(this.projectDir);
|
|
242
|
-
|
|
243
|
-
taskData =
|
|
563
|
+
allTasks = await loadAllTasks(ctx);
|
|
564
|
+
taskData = allTasks.find((t) => t._ulid === change.taskId);
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
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);
|
|
244
575
|
}
|
|
245
576
|
catch {
|
|
246
|
-
// Can't load
|
|
577
|
+
// Can't load tasks, dependency check will be skipped
|
|
247
578
|
}
|
|
248
579
|
}
|
|
249
580
|
// Make loaded task available for prompt building (AC: @agent-dispatch-engine ac-13)
|
|
@@ -255,14 +586,22 @@ export class DispatchEngine {
|
|
|
255
586
|
if (rule.on !== eventType)
|
|
256
587
|
continue;
|
|
257
588
|
// AC: @agent-dispatch-engine ac-6 - Apply filters
|
|
258
|
-
if (!this._matchesFilter(change, rule, taskData))
|
|
589
|
+
if (!this._matchesFilter(change, rule, taskData, allTasks))
|
|
259
590
|
continue;
|
|
260
591
|
// AC: @agent-dispatch-engine ac-2 - Each matching agent queued independently
|
|
261
592
|
this._enqueue(agent, change);
|
|
262
593
|
}
|
|
263
594
|
}
|
|
264
|
-
//
|
|
265
|
-
|
|
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
|
+
}
|
|
266
605
|
}
|
|
267
606
|
/**
|
|
268
607
|
* Handle file watcher notification: diff previous vs current task states.
|
|
@@ -310,15 +649,39 @@ export class DispatchEngine {
|
|
|
310
649
|
*/
|
|
311
650
|
async stop() {
|
|
312
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();
|
|
657
|
+
// AC: @agent-dispatch-engine ac-20 - Stop periodic reconciliation
|
|
658
|
+
if (this.reconcileTimer !== null) {
|
|
659
|
+
clearInterval(this.reconcileTimer);
|
|
660
|
+
this.reconcileTimer = null;
|
|
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();
|
|
313
674
|
// AC: @agent-dispatch-engine ac-11 - Send graceful cancel to all active invocations
|
|
314
675
|
for (const controller of this.invocationAbortControllers) {
|
|
315
676
|
controller.abort();
|
|
316
677
|
}
|
|
317
|
-
// 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.
|
|
318
682
|
if (this.runningInvocations.size > 0) {
|
|
319
683
|
await Promise.allSettled(Array.from(this.runningInvocations));
|
|
320
684
|
}
|
|
321
|
-
this.queues.clear();
|
|
322
685
|
this.activeCount.clear();
|
|
323
686
|
this.recentEvents.clear();
|
|
324
687
|
this.invocationAbortControllers.clear();
|
|
@@ -331,6 +694,9 @@ export class DispatchEngine {
|
|
|
331
694
|
getShadowMutex() {
|
|
332
695
|
return this.shadowMutex;
|
|
333
696
|
}
|
|
697
|
+
getCwd() {
|
|
698
|
+
return this.cwd;
|
|
699
|
+
}
|
|
334
700
|
/**
|
|
335
701
|
* Returns current engine status info including per-invocation details.
|
|
336
702
|
* AC: @cli-agent-commands ac-6
|
|
@@ -365,44 +731,148 @@ export class DispatchEngine {
|
|
|
365
731
|
* AC: @agent-dispatch-engine ac-8
|
|
366
732
|
*/
|
|
367
733
|
async _bootstrap() {
|
|
734
|
+
try {
|
|
735
|
+
const enqueued = await this._evaluateAllTasks({ skipIfActive: false });
|
|
736
|
+
if (enqueued > 0) {
|
|
737
|
+
// AC: @agent-dispatch-engine ac-27 — all drains go through _serializedDrain()
|
|
738
|
+
await this._serializedDrain();
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
catch (err) {
|
|
742
|
+
console.error("[dispatch] Bootstrap error:", err);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Periodic reconciliation: re-evaluate all task states against dispatch rules.
|
|
747
|
+
* Enqueues tasks that match but have no active or queued invocation.
|
|
748
|
+
* AC: @agent-dispatch-engine ac-19
|
|
749
|
+
*/
|
|
750
|
+
async _reconcile() {
|
|
368
751
|
try {
|
|
369
752
|
const ctx = await initContext(this.projectDir);
|
|
370
753
|
const tasks = await loadAllTasks(ctx);
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
+
});
|
|
765
|
+
const enqueued = await this._evaluateAllTasks({ skipIfActive: true });
|
|
766
|
+
if (enqueued > 0) {
|
|
767
|
+
console.log(`[dispatch] Reconciliation enqueued ${enqueued} task(s)`);
|
|
768
|
+
// AC: @agent-dispatch-engine ac-27 — all drains go through _serializedDrain()
|
|
769
|
+
await this._serializedDrain();
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Shared logic for bootstrap and reconciliation: load all tasks, seed
|
|
774
|
+
* prevTaskStates, and enqueue tasks matching agent dispatch rules.
|
|
775
|
+
*
|
|
776
|
+
* When skipIfActive is true (reconciliation), tasks with an existing
|
|
777
|
+
* active or queued invocation are skipped.
|
|
778
|
+
*
|
|
779
|
+
* AC: @agent-dispatch-engine ac-8, ac-19
|
|
780
|
+
*/
|
|
781
|
+
async _evaluateAllTasks(opts) {
|
|
782
|
+
const ctx = await initContext(this.projectDir);
|
|
783
|
+
const tasks = await loadAllTasks(ctx);
|
|
784
|
+
const agents = await this._loadAgents();
|
|
785
|
+
const now = Date.now();
|
|
786
|
+
let enqueued = 0;
|
|
787
|
+
// Seed/update prevTaskStates so file watcher diffs work correctly
|
|
788
|
+
for (const task of tasks) {
|
|
789
|
+
this.prevTaskStates.set(task._ulid, task.status);
|
|
790
|
+
}
|
|
791
|
+
for (const task of tasks) {
|
|
792
|
+
const currentStatus = task.status;
|
|
793
|
+
const eventType = STATUS_TO_EVENT[currentStatus];
|
|
794
|
+
if (!eventType)
|
|
795
|
+
continue;
|
|
796
|
+
for (const agent of agents) {
|
|
797
|
+
for (const rule of (agent.dispatch ?? [])) {
|
|
798
|
+
if (rule.on !== eventType)
|
|
799
|
+
continue;
|
|
800
|
+
const change = {
|
|
801
|
+
taskId: task._ulid,
|
|
802
|
+
taskRef: `@${task._ulid}`,
|
|
803
|
+
fromStatus: currentStatus,
|
|
804
|
+
toStatus: currentStatus,
|
|
805
|
+
timestamp: now,
|
|
806
|
+
task,
|
|
807
|
+
};
|
|
808
|
+
if (!this._matchesFilter(change, rule, task, tasks))
|
|
809
|
+
continue;
|
|
810
|
+
if (opts.skipIfActive && this._hasActiveOrQueuedInvocation(agent.id, task._ulid))
|
|
811
|
+
continue;
|
|
812
|
+
this._enqueue(agent, change);
|
|
813
|
+
enqueued++;
|
|
399
814
|
}
|
|
400
815
|
}
|
|
401
|
-
await this._drainQueues(agents);
|
|
402
816
|
}
|
|
403
|
-
|
|
404
|
-
|
|
817
|
+
return enqueued;
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Check if an agent already has an active or queued invocation for a task.
|
|
821
|
+
* AC: @agent-dispatch-engine ac-19
|
|
822
|
+
*/
|
|
823
|
+
_hasActiveOrQueuedInvocation(agentId, taskId) {
|
|
824
|
+
if (this.inFlightTaskKeys.has(`${agentId}:@${taskId}`)) {
|
|
825
|
+
return true;
|
|
826
|
+
}
|
|
827
|
+
// Check active invocations
|
|
828
|
+
for (const record of this.activeInvocationDetails.values()) {
|
|
829
|
+
if (record.agentId === agentId && record.taskRef === `@${taskId}`) {
|
|
830
|
+
return true;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
// Check queued entries
|
|
834
|
+
const queue = this.queues.get(agentId) ?? [];
|
|
835
|
+
return queue.some((entry) => entry.change.taskId === taskId);
|
|
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
|
+
}
|
|
405
874
|
}
|
|
875
|
+
return false;
|
|
406
876
|
}
|
|
407
877
|
/**
|
|
408
878
|
* Load agent definitions from meta context.
|
|
@@ -419,19 +889,42 @@ export class DispatchEngine {
|
|
|
419
889
|
}
|
|
420
890
|
/**
|
|
421
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
|
+
*
|
|
422
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
|
|
423
900
|
*/
|
|
424
|
-
_matchesFilter(change, rule, task) {
|
|
425
|
-
|
|
426
|
-
|
|
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";
|
|
427
905
|
// We need the task to evaluate filters — if not provided, reject to avoid
|
|
428
906
|
// enqueuing non-matching tasks (AC-6: all filters must match)
|
|
429
907
|
if (!task)
|
|
908
|
+
return !rule.filter && !defaultsToEligible;
|
|
909
|
+
// Any unresolved blocker excludes the candidate from scheduling.
|
|
910
|
+
if ((task.blocked_by ?? []).length > 0) {
|
|
430
911
|
return false;
|
|
431
|
-
|
|
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);
|
|
432
925
|
// Automation filter
|
|
433
|
-
if (
|
|
434
|
-
if (task.automation !==
|
|
926
|
+
if (effectiveAutomation !== undefined) {
|
|
927
|
+
if (task.automation !== effectiveAutomation) {
|
|
435
928
|
return false;
|
|
436
929
|
}
|
|
437
930
|
}
|
|
@@ -442,9 +935,11 @@ export class DispatchEngine {
|
|
|
442
935
|
return false;
|
|
443
936
|
}
|
|
444
937
|
}
|
|
445
|
-
// Priority filter
|
|
938
|
+
// Priority filter — threshold semantics: task priority at or above (numerically <=)
|
|
939
|
+
// AC: @ui-agent-dispatch ac-8
|
|
446
940
|
if (filter.priority !== undefined) {
|
|
447
|
-
|
|
941
|
+
const taskPriority = task.priority;
|
|
942
|
+
if (taskPriority === undefined || taskPriority > filter.priority) {
|
|
448
943
|
return false;
|
|
449
944
|
}
|
|
450
945
|
}
|
|
@@ -497,6 +992,7 @@ export class DispatchEngine {
|
|
|
497
992
|
nextRetryAt: 0,
|
|
498
993
|
enqueuedAtMs: Date.now(),
|
|
499
994
|
sequence: this.nextQueueSequence++,
|
|
995
|
+
starvationDeferrals: 0,
|
|
500
996
|
};
|
|
501
997
|
this._insertQueueEntry(queue, entry);
|
|
502
998
|
this.queues.set(agent.id, queue);
|
|
@@ -514,90 +1010,320 @@ export class DispatchEngine {
|
|
|
514
1010
|
queue.splice(insertAt, 0, entry);
|
|
515
1011
|
}
|
|
516
1012
|
/**
|
|
517
|
-
* Compare queue entries by dispatch precedence,
|
|
1013
|
+
* Compare queue entries by dispatch precedence, numeric task priority, then FIFO.
|
|
518
1014
|
* AC: @dispatch-in-progress-priority ac-1
|
|
519
1015
|
*/
|
|
520
1016
|
_compareQueueEntries(a, b) {
|
|
521
1017
|
const statusDelta = STATUS_PRECEDENCE[a.change.toStatus] - STATUS_PRECEDENCE[b.change.toStatus];
|
|
522
1018
|
if (statusDelta !== 0)
|
|
523
1019
|
return statusDelta;
|
|
1020
|
+
const priorityDelta = this._taskPriorityForEntry(a) - this._taskPriorityForEntry(b);
|
|
1021
|
+
if (priorityDelta !== 0)
|
|
1022
|
+
return priorityDelta;
|
|
524
1023
|
return a.sequence - b.sequence;
|
|
525
1024
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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;
|
|
533
1062
|
return;
|
|
534
|
-
// AC: @agent-dispatch-engine ac-17 - Load current task states once for staleness checks
|
|
535
|
-
let currentTaskStates;
|
|
536
|
-
try {
|
|
537
|
-
const ctx = await initContext(this.projectDir);
|
|
538
|
-
const tasks = await loadAllTasks(ctx);
|
|
539
|
-
currentTaskStates = new Map(tasks.map((t) => [t._ulid, t.status]));
|
|
540
1063
|
}
|
|
541
|
-
|
|
542
|
-
|
|
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;
|
|
543
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]));
|
|
544
1147
|
for (const agent of agents) {
|
|
545
|
-
const maxConcurrent = agent.concurrency?.max_concurrent ?? 1;
|
|
546
|
-
const active = this.activeCount.get(agent.id) ?? 0;
|
|
547
1148
|
const queue = this.queues.get(agent.id) ?? [];
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
const entry = queue[i];
|
|
556
|
-
const currentStatus = currentTaskStates.get(entry.change.taskId);
|
|
557
|
-
const expectedEvent = STATUS_TO_EVENT[entry.change.toStatus];
|
|
558
|
-
if (!expectedEvent)
|
|
559
|
-
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) {
|
|
560
1156
|
if (currentStatus === undefined) {
|
|
561
|
-
// Task not on disk — only discard if we previously knew about it
|
|
562
|
-
// (it was deleted). Tasks from pure handleStateChange events without
|
|
563
|
-
// on-disk presence should still be processed.
|
|
564
1157
|
if (this.prevTaskStates.has(entry.change.taskId)) {
|
|
1158
|
+
discardedDetails.push(`${entry.change.taskRef} for agent "${agent.id}": task no longer exists on disk`);
|
|
565
1159
|
queue.splice(i, 1);
|
|
1160
|
+
continue;
|
|
566
1161
|
}
|
|
567
1162
|
}
|
|
568
1163
|
else {
|
|
569
1164
|
const currentEvent = STATUS_TO_EVENT[currentStatus];
|
|
570
1165
|
if (currentEvent !== expectedEvent) {
|
|
1166
|
+
discardedDetails.push(`${entry.change.taskRef} for agent "${agent.id}": task state changed to ${currentStatus}`);
|
|
571
1167
|
queue.splice(i, 1);
|
|
1168
|
+
continue;
|
|
572
1169
|
}
|
|
573
1170
|
}
|
|
574
1171
|
}
|
|
575
|
-
|
|
576
|
-
|
|
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
|
+
}
|
|
577
1187
|
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
const nextReadyIndex = queue.findIndex((entry) => entry.nextRetryAt <= now);
|
|
583
|
-
if (nextReadyIndex === -1) {
|
|
584
|
-
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);
|
|
585
1192
|
}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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}"`);
|
|
590
1199
|
}
|
|
591
1200
|
this.queues.set(agent.id, queue);
|
|
592
1201
|
}
|
|
593
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
|
+
}
|
|
594
1320
|
/**
|
|
595
1321
|
* Build dispatch-mode prompt guardrails to keep autonomous agents from
|
|
596
1322
|
* stopping with handoff text instead of performing required actions.
|
|
597
1323
|
*
|
|
598
1324
|
* AC: @agent-dispatch-engine ac-13, ac-14, ac-15, ac-16
|
|
599
1325
|
*/
|
|
600
|
-
_buildDispatchPrompt(agent, change) {
|
|
1326
|
+
async _buildDispatchPrompt(agent, change, workspace) {
|
|
601
1327
|
const trigger = (STATUS_TO_EVENT[change.toStatus] ?? "task.ready");
|
|
602
1328
|
const taskRef = change.taskRef;
|
|
603
1329
|
// AC: @agent-dispatch-engine ac-16 - Interpolate prompt_template variables
|
|
@@ -609,8 +1335,13 @@ export class DispatchEngine {
|
|
|
609
1335
|
};
|
|
610
1336
|
const rawTemplate = agent.prompt_template ?? `Work on task ${taskRef}`;
|
|
611
1337
|
const basePrompt = interpolateTemplate(rawTemplate, templateVars);
|
|
612
|
-
// AC: @
|
|
613
|
-
|
|
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);
|
|
614
1345
|
const autonomousPreamble = [
|
|
615
1346
|
"AUTONOMOUS DISPATCH MODE (no interactive user is available).",
|
|
616
1347
|
"- Do not ask for confirmation, approval, or next-step handoff.",
|
|
@@ -618,6 +1349,7 @@ export class DispatchEngine {
|
|
|
618
1349
|
"- Do not end your turn with a recommendations-only summary. Perform the next required action yourself.",
|
|
619
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 \"...\"`.",
|
|
620
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.`,
|
|
621
1353
|
];
|
|
622
1354
|
const triggerSpecific = trigger === "task.pending_review"
|
|
623
1355
|
? [
|
|
@@ -632,160 +1364,432 @@ export class DispatchEngine {
|
|
|
632
1364
|
`- Perform the required commands to move ${taskRef} to the next appropriate state in this same invocation.`,
|
|
633
1365
|
"- If your workflow includes git or PR steps, execute them directly instead of deferring to a human.",
|
|
634
1366
|
];
|
|
635
|
-
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
|
+
}
|
|
636
1399
|
}
|
|
637
1400
|
/**
|
|
638
1401
|
* Spawn a single invocation for a queue entry.
|
|
639
1402
|
* Returns true if an invocation was actually started, false if skipped.
|
|
640
1403
|
* AC: @agent-dispatch-engine ac-9, ac-10, ac-11, ac-12
|
|
641
1404
|
*/
|
|
642
|
-
_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;
|
|
643
1410
|
const agentId = agent.id;
|
|
644
|
-
|
|
645
|
-
this.
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
+
});
|
|
661
1437
|
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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 });
|
|
696
1485
|
}
|
|
697
|
-
|
|
698
|
-
// Emit an empty sentinel so watch renderers can end the current line
|
|
699
|
-
// without needing to infer boundaries from prose punctuation.
|
|
700
|
-
this.onTextChunk(preSessionId, agentId, taskId, "");
|
|
1486
|
+
return false;
|
|
701
1487
|
}
|
|
702
|
-
|
|
703
|
-
const options = {
|
|
704
|
-
agent,
|
|
705
|
-
specDir: this.specDir,
|
|
706
|
-
cwd: this.cwd,
|
|
707
|
-
taskRef: entry.change.taskRef,
|
|
708
|
-
prompt: this._buildDispatchPrompt(agent, entry.change),
|
|
709
|
-
trigger: (STATUS_TO_EVENT[entry.change.toStatus] ?? "task.ready"),
|
|
710
|
-
kspecCliPath: this.kspecCliPath,
|
|
711
|
-
abortSignal: abortController.signal,
|
|
712
|
-
sessionId: preSessionId,
|
|
713
|
-
onUpdate,
|
|
714
|
-
};
|
|
715
|
-
// AC: @agent-dispatch-engine ac-12 - Wrap invocation in shadow mutex
|
|
716
|
-
const invocationPromise = this.shadowMutex
|
|
717
|
-
.runExclusive(async () => {
|
|
718
|
-
// AC: @agent-dispatch-engine ac-9 - Retry on transient errors
|
|
1488
|
+
let prompt;
|
|
719
1489
|
try {
|
|
720
|
-
await
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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;
|
|
724
1549
|
this.onInvocationEvent?.({
|
|
725
|
-
type: "
|
|
1550
|
+
type: "started",
|
|
726
1551
|
session_id: preSessionId,
|
|
727
1552
|
agent_id: agentId,
|
|
728
1553
|
task_id: entry.change.taskRef,
|
|
729
|
-
|
|
1554
|
+
task_title: entry.change.task?.title ?? null,
|
|
1555
|
+
status: "started",
|
|
730
1556
|
timestamp: Date.now(),
|
|
731
1557
|
});
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
this._loadAgents()
|
|
748
|
-
.then((agents) => this._drainQueues(agents))
|
|
749
|
-
.catch(() => { });
|
|
750
|
-
}
|
|
751
|
-
}, backoffMs);
|
|
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);
|
|
752
1573
|
}
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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
|
+
}
|
|
1639
|
+
}
|
|
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",
|
|
758
1658
|
session_id: preSessionId,
|
|
759
1659
|
agent_id: agentId,
|
|
760
1660
|
task_id: entry.change.taskRef,
|
|
761
|
-
|
|
1661
|
+
task_title: entry.change.task?.title ?? null,
|
|
1662
|
+
status: "completed",
|
|
762
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
|
+
});
|
|
763
1715
|
});
|
|
764
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);
|
|
765
1791
|
}
|
|
766
|
-
}
|
|
767
|
-
.then(async () => {
|
|
768
|
-
// Decrement active count and drain again
|
|
769
|
-
const currentActive = this.activeCount.get(agentId) ?? 1;
|
|
770
|
-
this.activeCount.set(agentId, Math.max(0, currentActive - 1));
|
|
771
|
-
if (!this.running)
|
|
772
|
-
return;
|
|
773
|
-
// Try to drain more items
|
|
774
|
-
try {
|
|
775
|
-
const agents = await this._loadAgents();
|
|
776
|
-
await this._drainQueues(agents);
|
|
777
|
-
}
|
|
778
|
-
catch {
|
|
779
|
-
// Best effort
|
|
780
|
-
}
|
|
781
|
-
})
|
|
782
|
-
.finally(() => {
|
|
783
|
-
this.runningInvocations.delete(invocationPromise);
|
|
784
|
-
this.invocationAbortControllers.delete(abortController);
|
|
785
|
-
this.activeInvocationDetails.delete(invocationId);
|
|
786
|
-
});
|
|
787
|
-
this.runningInvocations.add(invocationPromise);
|
|
788
|
-
return true;
|
|
1792
|
+
}
|
|
789
1793
|
}
|
|
790
1794
|
}
|
|
791
1795
|
//# sourceMappingURL=dispatch.js.map
|