@midhunxavier/code-flow 0.0.0-stage → 0.1.0-preview.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +141 -2
- package/bin/codeflow.mjs +8 -0
- package/bin/launcher.mjs +127 -0
- package/dist/remote-host.json +1 -0
- package/licenses/inter.txt +92 -0
- package/licenses/preact.txt +21 -0
- package/licenses/signals-core.txt +21 -0
- package/licenses/signals.txt +21 -0
- package/licenses/typescript-notices.txt +193 -0
- package/licenses/typescript.txt +55 -0
- package/licenses/uqr.txt +22 -0
- package/licenses/xterm-fit.txt +19 -0
- package/licenses/xterm.txt +21 -0
- package/package.json +34 -4
- package/public/assets/changes-62ZIEQSN.js +1 -0
- package/public/assets/chunk-2PZVOKID.js +1 -0
- package/public/assets/chunk-4D5IENEB.js +1 -0
- package/public/assets/chunk-5SARP5MU.js +1 -0
- package/public/assets/chunk-63YLZ3PR.js +1 -0
- package/public/assets/chunk-7FUHKF2M.js +1 -0
- package/public/assets/chunk-APQEVJ2K.js +1 -0
- package/public/assets/chunk-CGOZQMEZ.js +2 -0
- package/public/assets/chunk-J4VYS3KV.js +2 -0
- package/public/assets/chunk-L7TUCIYF.js +1 -0
- package/public/assets/chunk-SIHDWM2Y.js +154 -0
- package/public/assets/chunk-TEJVYIKC.js +1 -0
- package/public/assets/code-browser-VB2HGZSG.js +4 -0
- package/public/assets/filtered-tasks-ES5CP4KV.js +1 -0
- package/public/assets/interactive-browser-7F5ZXRUV.js +7 -0
- package/public/assets/join-project-6RTFHNCA.js +1 -0
- package/public/assets/main-65EW73SB.js +1 -0
- package/public/assets/project-invitations-view-GMPWGWYM.js +1 -0
- package/public/assets/settings-UB5Q4AQM.js +9 -0
- package/public/assets/styles-18792BB4.css +32 -0
- package/public/assets/team-server-wizard-2GLDWS6D.js +8 -0
- package/public/assets/terminal-IWAIM4DE.js +62 -0
- package/public/assets.json +1 -0
- package/public/bundle-meta.json +1 -0
- package/public/docs/.nojekyll +0 -0
- package/public/docs/accessibility.html +19 -0
- package/public/docs/acp-agents.html +117 -0
- package/public/docs/agent-containers.html +79 -0
- package/public/docs/api.html +1130 -0
- package/public/docs/app.js +342 -0
- package/public/docs/application-improvements.html +275 -0
- package/public/docs/architecture-explorer.html +19 -0
- package/public/docs/architecture.html +149 -0
- package/public/docs/auto-team.html +148 -0
- package/public/docs/automations.html +109 -0
- package/public/docs/checkpoint-review.html +37 -0
- package/public/docs/code-hosts.html +35 -0
- package/public/docs/code-intelligence.html +35 -0
- package/public/docs/configuration.html +346 -0
- package/public/docs/context-inspection.html +23 -0
- package/public/docs/coordinated-changes.html +28 -0
- package/public/docs/deferred-runs.html +25 -0
- package/public/docs/dependency-discovery.html +19 -0
- package/public/docs/documentation.html +84 -0
- package/public/docs/dynamic-teams-evaluation.html +174 -0
- package/public/docs/dynamic-teams-implementation-plan.html +882 -0
- package/public/docs/dynamic-teams-implementation-review-1.html +141 -0
- package/public/docs/dynamic-teams-plan-review-0.html +204 -0
- package/public/docs/extensions.html +42 -0
- package/public/docs/external-sources.html +96 -0
- package/public/docs/filesystem-policy.html +26 -0
- package/public/docs/filtered-tasks.html +44 -0
- package/public/docs/font-license.txt +92 -0
- package/public/docs/getting-started.html +143 -0
- package/public/docs/github-review-threads.html +26 -0
- package/public/docs/github.html +157 -0
- package/public/docs/help.html +69 -0
- package/public/docs/index.html +88 -0
- package/public/docs/installation.html +80 -0
- package/public/docs/launch-profiles.html +48 -0
- package/public/docs/maintenance.html +60 -0
- package/public/docs/memory-claims.html +31 -0
- package/public/docs/memory-storage.html +58 -0
- package/public/docs/notifications.html +39 -0
- package/public/docs/patterns.html +195 -0
- package/public/docs/performance-results.html +210 -0
- package/public/docs/performance.html +166 -0
- package/public/docs/project-conventions.html +22 -0
- package/public/docs/project-first-navigation-plan.html +587 -0
- package/public/docs/project-teams-implementation-plan.html +772 -0
- package/public/docs/project-teams.html +129 -0
- package/public/docs/provider-compatibility.html +20 -0
- package/public/docs/releasing.html +65 -0
- package/public/docs/remote-workers.html +64 -0
- package/public/docs/review-inbox.html +26 -0
- package/public/docs/run-storage.html +23 -0
- package/public/docs/runs.html +141 -0
- package/public/docs/search.json +1 -0
- package/public/docs/security.html +133 -0
- package/public/docs/shared-projects.html +31 -0
- package/public/docs/ssh-projects.html +60 -0
- package/public/docs/style.css +768 -0
- package/public/docs/task-reruns.html +30 -0
- package/public/docs/team-collaboration.html +412 -0
- package/public/docs/verification.html +22 -0
- package/public/docs/vscode.html +53 -0
- package/public/docs/worker-permission-verification.html +131 -0
- package/public/index.html +17 -0
- package/scripts/check-node.mjs +7 -0
- package/scripts/codeflow.mjs +55 -0
- package/scripts/computer-use.swift +64 -0
- package/scripts/pty.py +273 -0
- package/src/acp-connections.js +110 -0
- package/src/adapters/acp-presets.js +37 -0
- package/src/adapters/acp-types.js +0 -0
- package/src/adapters/acp.js +328 -0
- package/src/adapters/agent-tools.js +28 -0
- package/src/adapters/agy-auth.js +67 -0
- package/src/adapters/agy-isolation.js +242 -0
- package/src/adapters/agy-tools.js +45 -0
- package/src/adapters/agy.js +326 -0
- package/src/adapters/claude-stream.js +58 -0
- package/src/adapters/claude.js +282 -0
- package/src/adapters/codex-app-server.js +555 -0
- package/src/adapters/codex.js +311 -0
- package/src/adapters/errors.js +57 -0
- package/src/adapters/index.js +91 -0
- package/src/adapters/managed-permissions.js +37 -0
- package/src/adapters/model-discovery.js +100 -0
- package/src/adapters/process.js +433 -0
- package/src/adapters/team-tools.js +103 -0
- package/src/adapters/types.js +0 -0
- package/src/agent-browser.js +688 -0
- package/src/agent-computer.js +88 -0
- package/src/agent-container.js +155 -0
- package/src/agent-team-definitions.js +131 -0
- package/src/agent-tool-client.mjs +79 -0
- package/src/agent-tools.js +296 -0
- package/src/approvals.js +134 -0
- package/src/architecture.js +456 -0
- package/src/assets.js +44 -0
- package/src/automation.js +901 -0
- package/src/browser-files.js +293 -0
- package/src/browser-ownership.js +150 -0
- package/src/certificate-renewal.js +75 -0
- package/src/chat-family.js +25 -0
- package/src/chat-handoff.js +87 -0
- package/src/checkpoint-review.js +72 -0
- package/src/checks.js +303 -0
- package/src/code-host-git.js +102 -0
- package/src/code-hosts-api.js +99 -0
- package/src/code-hosts.js +482 -0
- package/src/code-intelligence-worker.mjs +452 -0
- package/src/code-intelligence.js +141 -0
- package/src/collab/api.js +365 -0
- package/src/collab/checkpoints.js +198 -0
- package/src/collab/connections.js +139 -0
- package/src/collab/host-execution.js +347 -0
- package/src/collab/hub-client.js +210 -0
- package/src/collab/link-api.js +229 -0
- package/src/collab/link.js +595 -0
- package/src/collab/model.js +496 -0
- package/src/collab/runner.js +1014 -0
- package/src/collab/store.js +1452 -0
- package/src/collab/terminals.js +361 -0
- package/src/collaboration-reviews.js +116 -0
- package/src/config.js +48 -0
- package/src/context-budget.js +14 -0
- package/src/context-inspection.js +204 -0
- package/src/context.js +154 -0
- package/src/conventions.js +283 -0
- package/src/coordinated-api.js +247 -0
- package/src/deferred-launch.js +82 -0
- package/src/deferred-runs.js +515 -0
- package/src/dependency-health.js +35 -0
- package/src/diff.js +43 -0
- package/src/docs-help.js +316 -0
- package/src/doctor.js +232 -0
- package/src/documentation-changes.js +20 -0
- package/src/engine.js +5258 -0
- package/src/env.js +44 -0
- package/src/execution-budget.js +8 -0
- package/src/experience.js +50 -0
- package/src/extensions.js +144 -0
- package/src/filesystem-access.js +107 -0
- package/src/filesystem-policy.js +238 -0
- package/src/filtered-task-limits.js +93 -0
- package/src/filtered-tasks-api.js +53 -0
- package/src/filtered-tasks.js +576 -0
- package/src/frame-policy.js +66 -0
- package/src/fs.js +57 -0
- package/src/git.js +626 -0
- package/src/github-review-threads.js +181 -0
- package/src/github.js +538 -0
- package/src/http.js +69 -0
- package/src/improve-settings.js +38 -0
- package/src/improve.js +187 -0
- package/src/integrations.js +373 -0
- package/src/integrity.js +259 -0
- package/src/journey-api.js +77 -0
- package/src/journey-replay.js +65 -0
- package/src/journey-store.js +97 -0
- package/src/journeys.js +60 -0
- package/src/json.js +81 -0
- package/src/launch-profiles.js +292 -0
- package/src/learnings.js +80 -0
- package/src/local-app-url.js +19 -0
- package/src/log.js +39 -0
- package/src/maintenance.js +335 -0
- package/src/memory-analysis.js +182 -0
- package/src/memory-claims.js +153 -0
- package/src/memory-sqlite.js +236 -0
- package/src/memory.js +552 -0
- package/src/merge-validation.js +58 -0
- package/src/model-catalog.js +239 -0
- package/src/model-research.js +91 -0
- package/src/notes.js +176 -0
- package/src/notifications.js +413 -0
- package/src/patterns/auto-team-model.js +230 -0
- package/src/patterns/auto-team.js +816 -0
- package/src/patterns/council.js +199 -0
- package/src/patterns/debate.js +188 -0
- package/src/patterns/findings.js +141 -0
- package/src/patterns/index.js +21 -0
- package/src/patterns/loop.js +158 -0
- package/src/patterns/pipeline.js +125 -0
- package/src/patterns/prompts.js +181 -0
- package/src/patterns/race.js +147 -0
- package/src/patterns/schemas.js +40 -0
- package/src/patterns/solo.js +46 -0
- package/src/patterns/supervisor.js +235 -0
- package/src/patterns/task-draft.js +29 -0
- package/src/patterns/task-review.js +45 -0
- package/src/patterns/tdd.js +231 -0
- package/src/patterns/team-strategies.js +67 -0
- package/src/performance.js +43 -0
- package/src/persistence-status.js +35 -0
- package/src/phone-sessions.js +96 -0
- package/src/preview-overlay.js +392 -0
- package/src/preview.js +230 -0
- package/src/privacy.js +23 -0
- package/src/project-report.js +22 -0
- package/src/project-team-budget.js +22 -0
- package/src/project-team-model.js +225 -0
- package/src/project-team-policy.js +42 -0
- package/src/project-team-store.js +234 -0
- package/src/project-team-usage.js +24 -0
- package/src/project-teams-api.js +43 -0
- package/src/project-teams.js +644 -0
- package/src/projects.js +290 -0
- package/src/provider-eligibility.js +9 -0
- package/src/readiness.js +98 -0
- package/src/remote-tls.js +66 -0
- package/src/remote-workers/bundles.js +198 -0
- package/src/remote-workers/client.js +332 -0
- package/src/remote-workers/durability.js +37 -0
- package/src/remote-workers/protocol.js +68 -0
- package/src/remote-workers/stdio.js +138 -0
- package/src/remote-workers/worker.js +304 -0
- package/src/remote.js +204 -0
- package/src/repo-config.js +201 -0
- package/src/repo-coordination.js +725 -0
- package/src/repo-tree.js +106 -0
- package/src/repository-identity.js +13 -0
- package/src/retention-policy.js +466 -0
- package/src/review-inbox.js +38 -0
- package/src/routing.js +49 -0
- package/src/run-analysis.js +111 -0
- package/src/run-cold-store.js +231 -0
- package/src/run-environment.js +15 -0
- package/src/run-history.js +91 -0
- package/src/run-log.js +160 -0
- package/src/run-permissions.js +111 -0
- package/src/run-projection.js +77 -0
- package/src/run-recovery.js +53 -0
- package/src/run-status.js +56 -0
- package/src/serve.js +138 -0
- package/src/server.js +2871 -0
- package/src/shared-access.js +103 -0
- package/src/shared-accounts.js +381 -0
- package/src/shared-api.js +239 -0
- package/src/shell.js +346 -0
- package/src/ship.js +344 -0
- package/src/slots.js +74 -0
- package/src/snapshot.js +388 -0
- package/src/source-context.js +17 -0
- package/src/sse-writer.js +70 -0
- package/src/ssh-projects/bootstrap.mjs +95 -0
- package/src/ssh-projects/config.js +45 -0
- package/src/ssh-projects/gateway.js +143 -0
- package/src/ssh-projects/manager.js +320 -0
- package/src/ssh-projects/team-autostart.mjs +93 -0
- package/src/ssh-projects/team-remove.mjs +69 -0
- package/src/ssh-projects/team-status.mjs +39 -0
- package/src/ssh-projects/transport.js +133 -0
- package/src/stats.js +139 -0
- package/src/store.js +544 -0
- package/src/stream.js +91 -0
- package/src/sweeps.js +241 -0
- package/src/tailscale.js +101 -0
- package/src/task-draft.js +23 -0
- package/src/task-team.js +164 -0
- package/src/team-evaluation.js +243 -0
- package/src/team-mailbox.js +160 -0
- package/src/team-memory.js +84 -0
- package/src/team-rerun-graph.js +15 -0
- package/src/team-reruns.js +130 -0
- package/src/team-scheduler.js +121 -0
- package/src/team-steering.js +21 -0
- package/src/team-template-store.js +98 -0
- package/src/team-templates.js +117 -0
- package/src/team-tool-client.mjs +79 -0
- package/src/team-tools.js +235 -0
- package/src/teams.js +0 -0
- package/src/templates.js +62 -0
- package/src/transcript.js +134 -0
- package/src/verification-integrity.js +27 -0
- package/src/verification.js +183 -0
- package/src/watch.js +195 -0
- package/src/work-queue.js +47 -0
- package/src/workflow-control.js +83 -0
- package/src/workspace-api.js +207 -0
- package/src/workspace-changes.js +61 -0
- package/src/workspace.js +146 -0
|
@@ -0,0 +1,1014 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { copyFile, mkdir, open, readdir, realpath, rename, rm } from "node:fs/promises";
|
|
4
|
+
import { homedir, tmpdir } from "node:os";
|
|
5
|
+
import { isAbsolute, join } from "node:path";
|
|
6
|
+
import { CODEFLOW_HOME } from "../config.js";
|
|
7
|
+
import { assertWorktree, commitAll, git, repoRoot, resolveCommit, uncommittedCount } from "../git.js";
|
|
8
|
+
import { logError } from "../log.js";
|
|
9
|
+
import { redactText } from "../privacy.js";
|
|
10
|
+
import { assertTransferTree } from "../remote-workers/bundles.js";
|
|
11
|
+
import { taskDraftPattern } from "../patterns/task-draft.js";
|
|
12
|
+
import { checkpointContext, checkpointRef, createCheckpointBundle, ensureCheckpointBranch, importCheckpointBundle } from "./checkpoints.js";
|
|
13
|
+
import { ATTEMPT_ID, busyAttempt, CHECKPOINT_ID, CollabError, COMMIT_ID, latestCheckpoint, roleAtLeast, MAX, newAttemptId, newCheckpointId, ownerChangedMessage, TASK_ID } from "./model.js";
|
|
14
|
+
const PUBLISH_INTERVAL_MS = 2e3, HEARTBEAT_INTERVAL_MS = 2e4, RETRY_INTERVAL_MS = 6e4;
|
|
15
|
+
const MAX_FINISH_TRIES = 48;
|
|
16
|
+
const RETRY_BASE_MS = 3e4, RETRY_MAX_MS = 30 * 6e4;
|
|
17
|
+
const HUB_FINAL = /* @__PURE__ */ new Set([401, 403, 409]);
|
|
18
|
+
const LOCAL_STATES = ["claiming", "claimed", "running", "finished", "failed", "paused", "stopped", "unknown"];
|
|
19
|
+
const RECORD_LIMIT = 256 * 1024, MAX_DELIVERED = 200;
|
|
20
|
+
const object = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
21
|
+
const count = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
22
|
+
const CONTROL = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, LINE_CONTROL = /[\x00-\x1f\x7f]/g;
|
|
23
|
+
function clean(value, max, singleLine = false) {
|
|
24
|
+
let text = typeof value === "string" ? value.replace(singleLine ? LINE_CONTROL : CONTROL, " ") : "";
|
|
25
|
+
if (singleLine) text = text.trim();
|
|
26
|
+
const chars = [...text];
|
|
27
|
+
if (max >= 1024 && max % 1024 === 0) return Buffer.byteLength(text) <= max ? text : chars.slice(0, fitBytes(chars, max)).join("");
|
|
28
|
+
return chars.length <= max ? text : `${chars.slice(0, max - 1).join("")}\u2026`;
|
|
29
|
+
}
|
|
30
|
+
function fitBytes(chars, max, fromEnd = false) {
|
|
31
|
+
let low = 0, high = chars.length;
|
|
32
|
+
while (low < high) {
|
|
33
|
+
const mid = low + high + 1 >> 1, slice = fromEnd ? chars.slice(chars.length - mid) : chars.slice(0, mid);
|
|
34
|
+
if (Buffer.byteLength(slice.join("")) <= max) low = mid;
|
|
35
|
+
else high = mid - 1;
|
|
36
|
+
}
|
|
37
|
+
return low;
|
|
38
|
+
}
|
|
39
|
+
function tail(value, max) {
|
|
40
|
+
const text = clean(value, Number.MAX_SAFE_INTEGER);
|
|
41
|
+
if (Buffer.byteLength(text) <= max) return text;
|
|
42
|
+
const chars = [...text];
|
|
43
|
+
return `\u2026${chars.slice(chars.length - fitBytes(chars, max - 3, true)).join("")}`;
|
|
44
|
+
}
|
|
45
|
+
const message = (error) => error instanceof Error ? error.message : String(error);
|
|
46
|
+
const asCollab = (error, status = 400) => error instanceof CollabError ? error : new CollabError(message(error), status);
|
|
47
|
+
const retryDelay = (tries) => Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** Math.min(Math.max(0, tries - 1), 20));
|
|
48
|
+
const PLACEHOLDER = "<local path>", PATH_TAIL = "[^\\s\"'`)\\]<>,;:]*";
|
|
49
|
+
const ABSOLUTE_PATH = new RegExp(`(?<=^|[\\s"'\`(\\[<=,])(?:\\/|~\\/|[A-Za-z]:[\\\\/])${PATH_TAIL}`, "g"), PLACEHOLDER_TAIL = new RegExp(`${PLACEHOLDER}${PATH_TAIL}`, "g");
|
|
50
|
+
function scrubLocalPaths(text, known = []) {
|
|
51
|
+
let out = text;
|
|
52
|
+
for (const path of [...new Set(known.filter((entry) => !!entry && isAbsolute(entry)))].sort((a, b) => b.length - a.length)) out = out.split(path).join(PLACEHOLDER);
|
|
53
|
+
return out.replace(ABSOLUTE_PATH, PLACEHOLDER).replace(PLACEHOLDER_TAIL, PLACEHOLDER);
|
|
54
|
+
}
|
|
55
|
+
const localPaths = (run) => [run.repo, ...(run.worktrees ?? []).map((worktree) => worktree.path), CODEFLOW_HOME, tmpdir(), homedir()].filter((path) => !!path);
|
|
56
|
+
function sharedText(value, max, singleLine = false, paths = []) {
|
|
57
|
+
return clean(scrubLocalPaths(redactText(typeof value === "string" ? value : ""), paths), max, singleLine);
|
|
58
|
+
}
|
|
59
|
+
function progressFromRun(run, extra = {}, now = Date.now()) {
|
|
60
|
+
const share = (value, max, singleLine = false) => sharedText(value, max, singleLine, localPaths(run));
|
|
61
|
+
const steps = run.steps ?? [], progress = { updatedAt: now, runStatus: clean(run.status, MAX.runStatus, true) || "running" };
|
|
62
|
+
const running = [...steps].reverse().find((step) => step.status === "running");
|
|
63
|
+
const stage = running ? `${running.title}${running.role && running.role !== running.title ? ` (${running.role})` : ""}` : run.status === "running" ? steps.length ? "Between steps" : "Preparing the worktree" : "";
|
|
64
|
+
if (stage) progress.stage = share(stage, MAX.stage, true);
|
|
65
|
+
const summary = share(run.summary, MAX.summary);
|
|
66
|
+
if (summary.trim()) progress.summary = summary;
|
|
67
|
+
progress.steps = { done: steps.filter((step) => step.status !== "running").length, total: steps.length };
|
|
68
|
+
if (run.verdict === "passed" || run.verdict === "failing") progress.verdict = run.verdict;
|
|
69
|
+
if (run.lastCheck) progress.tests = { ok: run.lastCheck.ok, command: share(run.lastCheck.command, MAX.command, true) || "check", at: count(run.lastCheck.at) ? run.lastCheck.at : now };
|
|
70
|
+
const blockers = [];
|
|
71
|
+
if (run.awaiting) blockers.push(share(`Waiting for approval: ${run.awaiting.title}`, MAX.blockerItem, true));
|
|
72
|
+
const question = run.team?.awaitingRequestId ? run.team.messages.find((entry) => entry.requestId === run.team.awaitingRequestId || entry.id === run.team.awaitingRequestId) : void 0;
|
|
73
|
+
if (question) blockers.push(share(`Question from ${question.sender}: ${question.body}`, MAX.blockerItem, true));
|
|
74
|
+
else if (run.team?.awaitingRequestId) blockers.push("Waiting for an answer to the team's question");
|
|
75
|
+
if (blockers.length) progress.blockers = blockers.slice(0, MAX.blockers);
|
|
76
|
+
const branch = run.worktrees[0]?.branch;
|
|
77
|
+
if (branch && branch.length <= MAX.branch) progress.branch = share(branch, MAX.branch, true);
|
|
78
|
+
const head = extra.head?.toLowerCase();
|
|
79
|
+
if (head && COMMIT_ID.test(head)) progress.head = head;
|
|
80
|
+
if (extra.changedFiles?.length) progress.changedFiles = extra.changedFiles.slice(0, MAX.changedFiles).map((path) => share(path, MAX.path, true)).filter(Boolean);
|
|
81
|
+
return progress;
|
|
82
|
+
}
|
|
83
|
+
function mapRunStatus(run) {
|
|
84
|
+
const scrub = (text) => sharedText(text, MAX.reason, false, localPaths(run));
|
|
85
|
+
switch (run.status) {
|
|
86
|
+
case "done":
|
|
87
|
+
return { state: "finished" };
|
|
88
|
+
case "failed":
|
|
89
|
+
return { state: "failed", reason: scrub(run.error) || "The run failed" };
|
|
90
|
+
case "cancelled":
|
|
91
|
+
return { state: "paused", reason: "Stopped on this computer; resume it from here to continue" };
|
|
92
|
+
case "stopped":
|
|
93
|
+
return { state: "stopped", reason: scrub(run.stopReason) || "The run stopped at a limit; resume it to continue" };
|
|
94
|
+
case "interrupted":
|
|
95
|
+
return { state: "paused", reason: "interrupted by a restart; resume from this computer" };
|
|
96
|
+
default:
|
|
97
|
+
throw new CollabError("The run is still running; stop it before finishing the attempt", 409);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function taskPrompt(task, context = {}) {
|
|
101
|
+
const section = (title, body) => body.trim() ? `## ${title}
|
|
102
|
+
|
|
103
|
+
${body.trim()}` : "";
|
|
104
|
+
const parts = [
|
|
105
|
+
`Team task ${task.id} in project ${context.projectName?.trim() || task.projectId}; your result will be published to the team board.`,
|
|
106
|
+
`# ${task.title.trim()}`,
|
|
107
|
+
section("Goal", task.goal),
|
|
108
|
+
section("Acceptance criteria", task.acceptance),
|
|
109
|
+
task.decisions.length ? section("Decisions already made", task.decisions.slice(0, MAX.decisions).map((decision) => `- ${decision.text}`).join("\n")) : "",
|
|
110
|
+
context.checkpoint ? checkpointContext(context.checkpoint) : ""
|
|
111
|
+
];
|
|
112
|
+
return parts.filter(Boolean).join("\n\n").trimEnd() + "\n";
|
|
113
|
+
}
|
|
114
|
+
function instructionNote(instruction) {
|
|
115
|
+
return `Instruction from ${clean(instruction.by.displayName, MAX.displayName, true) || "a teammate"} via the team board:
|
|
116
|
+
${clean(instruction.text, MAX.text)}`;
|
|
117
|
+
}
|
|
118
|
+
function validateRecord(raw) {
|
|
119
|
+
if (!object(raw) || typeof raw.attemptId !== "string" || !ATTEMPT_ID.test(raw.attemptId) || typeof raw.taskId !== "string" || !TASK_ID.test(raw.taskId) || typeof raw.repo !== "string" || !isAbsolute(raw.repo) || typeof raw.base !== "string" || !COMMIT_ID.test(raw.base)) return void 0;
|
|
120
|
+
if (typeof raw.state !== "string" || !LOCAL_STATES.includes(raw.state) || !count(raw.lastSeq) || !count(raw.lastAckedSeq) || !count(raw.updatedAt) || !Array.isArray(raw.deliveredInstructionIds) || raw.deliveredInstructionIds.some((id) => typeof id !== "string" || id.length > 40)) return void 0;
|
|
121
|
+
const record = { attemptId: raw.attemptId, taskId: raw.taskId, repo: raw.repo, base: raw.base, state: raw.state, lastSeq: raw.lastSeq, lastAckedSeq: raw.lastAckedSeq, deliveredInstructionIds: raw.deliveredInstructionIds.slice(0, MAX_DELIVERED), updatedAt: raw.updatedAt };
|
|
122
|
+
if (typeof raw.hubUrl === "string" && raw.hubUrl.length <= 2048) record.hubUrl = raw.hubUrl;
|
|
123
|
+
if (typeof raw.runId === "string" && /^[a-zA-Z0-9_-]{1,100}$/.test(raw.runId)) record.runId = raw.runId;
|
|
124
|
+
if (typeof raw.checkpointId === "string" && CHECKPOINT_ID.test(raw.checkpointId)) record.checkpointId = raw.checkpointId;
|
|
125
|
+
if (object(raw.pendingFinish) && typeof raw.pendingFinish.state === "string" && ["finished", "failed", "stopped", "paused", "unknown"].includes(raw.pendingFinish.state)) {
|
|
126
|
+
const p = raw.pendingFinish, pending = { state: p.state, error: typeof p.error === "string" ? p.error.slice(0, 1e3) : "", at: count(p.at) ? p.at : 0, tries: count(p.tries) ? p.tries : 0 };
|
|
127
|
+
if (typeof p.head === "string" && COMMIT_ID.test(p.head)) pending.head = p.head;
|
|
128
|
+
if (typeof p.summary === "string") pending.summary = clean(p.summary, MAX.summary);
|
|
129
|
+
if (typeof p.reason === "string") pending.reason = clean(p.reason, MAX.reason);
|
|
130
|
+
if (typeof p.bundleFile === "string" && isAbsolute(p.bundleFile)) pending.bundleFile = p.bundleFile;
|
|
131
|
+
record.pendingFinish = pending;
|
|
132
|
+
}
|
|
133
|
+
return record;
|
|
134
|
+
}
|
|
135
|
+
async function embeddedCheckpointId(repo, file) {
|
|
136
|
+
const heads = await git(["bundle", "list-heads", file], repo, { timeoutMs: 3e4 });
|
|
137
|
+
if (heads.code) throw new CollabError(`Git cannot read the downloaded checkpoint bundle (${heads.stderr.trim().slice(-300) || "unknown error"}); download it again`);
|
|
138
|
+
const lines = heads.stdout.trim().split("\n").filter(Boolean), match = lines.length === 1 ? /^[a-f0-9]{40,64} refs\/codeflow\/team\/([a-f0-9]{32})$/.exec(lines[0]) : null;
|
|
139
|
+
if (!match) throw new CollabError("The checkpoint bundle does not advertise exactly one team checkpoint reference; download it again or delete the checkpoint");
|
|
140
|
+
return match[1];
|
|
141
|
+
}
|
|
142
|
+
async function assertCheckpointable(repo, commit) {
|
|
143
|
+
try {
|
|
144
|
+
await assertTransferTree(repo, commit);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
const text = message(error), why = text.includes("submodule") ? "it has a submodule" : text.includes("checkout filters") ? "it configures a checkout filter such as Git LFS in .gitattributes" : text.includes("20,000 files") ? "its tree exceeds 20,000 files, 256 MiB or 100 attribute files" : text.includes("256 KiB") ? "its checkout attributes exceed 256 KiB" : scrubLocalPaths(text, [repo]);
|
|
147
|
+
throw new CollabError(`This repository cannot carry team checkpoints (${why}); use a repository without submodules or checkout filters for board tasks`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const emptyResult = () => ({ attached: 0, finished: 0, removed: 0, unknown: 0, retried: 0 });
|
|
151
|
+
class CollabRunner {
|
|
152
|
+
constructor(options) {
|
|
153
|
+
this.options = options;
|
|
154
|
+
this.dir = options.dir ?? join(CODEFLOW_HOME, "team-link");
|
|
155
|
+
this.attemptsDir = join(this.dir, "attempts");
|
|
156
|
+
this.bundlesDir = join(this.dir, "bundles");
|
|
157
|
+
this.now = options.now ?? Date.now;
|
|
158
|
+
const interval = options.retryIntervalMs ?? RETRY_INTERVAL_MS;
|
|
159
|
+
if (interval > 0) {
|
|
160
|
+
this.retryTimer = setInterval(() => void this.retryPending().catch((error) => logError("The team attempt recovery pass failed; it runs again in a minute", error)), interval);
|
|
161
|
+
this.retryTimer.unref?.();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
dir;
|
|
165
|
+
attemptsDir;
|
|
166
|
+
bundlesDir;
|
|
167
|
+
records = /* @__PURE__ */ new Map();
|
|
168
|
+
loading;
|
|
169
|
+
journalVersions = /* @__PURE__ */ new Map();
|
|
170
|
+
active = /* @__PURE__ */ new Map();
|
|
171
|
+
chains = /* @__PURE__ */ new Map();
|
|
172
|
+
closed = false;
|
|
173
|
+
/** Attempts between startAttempt's first journal write and its attach(): a recovery pass must not read them as claims that never got their answer. */
|
|
174
|
+
starting = /* @__PURE__ */ new Set();
|
|
175
|
+
retryTimer;
|
|
176
|
+
now;
|
|
177
|
+
/** The hub as currently linked; the provider lets the server swap a LocalHub for a RemoteHub when the link changes. */
|
|
178
|
+
get hub() {
|
|
179
|
+
return this.options.hub();
|
|
180
|
+
}
|
|
181
|
+
get manager() {
|
|
182
|
+
return this.options.manager;
|
|
183
|
+
}
|
|
184
|
+
/** Planning is a local run, with no task/attempt claim or publication to the shared hub. */
|
|
185
|
+
async draftTask(input) {
|
|
186
|
+
this.assertOpen();
|
|
187
|
+
const project = (await this.hub.board(input.projectId)).projects.find((entry) => entry.id === input.projectId);
|
|
188
|
+
if (!project) throw new CollabError("Project not found", 404);
|
|
189
|
+
if (!roleAtLeast(project.role, "contributor")) throw new CollabError("A project contributor role is required to draft tasks", 403);
|
|
190
|
+
if (!project.repositories.some((entry) => entry.key === input.repository)) throw new CollabError("Choose a repository belonging to this project");
|
|
191
|
+
const repo = await this.resolveRepo(input, input.repo);
|
|
192
|
+
const run = await this.manager.start({
|
|
193
|
+
pattern: taskDraftPattern,
|
|
194
|
+
repo,
|
|
195
|
+
baseRef: input.baseRef || "HEAD",
|
|
196
|
+
task: input.idea,
|
|
197
|
+
roles: { agent: [input.agent] },
|
|
198
|
+
options: {},
|
|
199
|
+
agentSettings: input.agentSettings,
|
|
200
|
+
trust: "safe",
|
|
201
|
+
approvals: { plan: false, tools: false },
|
|
202
|
+
loop: { check: "", setup: "", copy: [], maxMinutes: 5, maxSteps: 2, maxInvocations: 2 },
|
|
203
|
+
project: this.options.projectFor?.(repo)
|
|
204
|
+
});
|
|
205
|
+
return { runId: run.id };
|
|
206
|
+
}
|
|
207
|
+
// ---- Local records: journaled before every step the hub must see at most once.
|
|
208
|
+
/** Re-reading the files never replaces a record that work in progress holds (a run being followed, a start under way, or an operation queued on its chain): that object carries changes its save has not finished writing, and a stale copy from disk would make the next pass publish the finish a second time or reuse a report number. */
|
|
209
|
+
load(force = false) {
|
|
210
|
+
if (force) this.loading = void 0;
|
|
211
|
+
return this.loading ??= (async () => {
|
|
212
|
+
const versions = new Map(this.journalVersions), busy = /* @__PURE__ */ new Set([...this.active.keys(), ...this.starting, ...this.chains.keys()]);
|
|
213
|
+
const protectedRecord = (id) => busy.has(id) || versions.get(id) !== this.journalVersions.get(id) || this.active.has(id) || this.starting.has(id) || this.chains.has(id);
|
|
214
|
+
let names;
|
|
215
|
+
try {
|
|
216
|
+
names = await readdir(this.attemptsDir);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (error.code !== "ENOENT") logError(`Couldn't list ${this.attemptsDir}; local attempt records are unavailable`, error);
|
|
219
|
+
names = [];
|
|
220
|
+
}
|
|
221
|
+
const seen = /* @__PURE__ */ new Set();
|
|
222
|
+
for (const name of names) {
|
|
223
|
+
if (!/^[a-f0-9]{32}\.json$/.test(name)) continue;
|
|
224
|
+
const file = join(this.attemptsDir, name);
|
|
225
|
+
try {
|
|
226
|
+
const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
227
|
+
let body;
|
|
228
|
+
try {
|
|
229
|
+
const info = await handle.stat();
|
|
230
|
+
if (!info.isFile() || info.size > RECORD_LIMIT) throw new Error("not a regular file within 256 KiB");
|
|
231
|
+
body = await handle.readFile("utf8");
|
|
232
|
+
} finally {
|
|
233
|
+
await handle.close();
|
|
234
|
+
}
|
|
235
|
+
const record = validateRecord(JSON.parse(body));
|
|
236
|
+
if (!record || `${record.attemptId}.json` !== name) throw new Error("the record does not describe the attempt its file is named after");
|
|
237
|
+
if (record.hubUrl !== this.hub.url) continue;
|
|
238
|
+
if (protectedRecord(record.attemptId)) {
|
|
239
|
+
seen.add(record.attemptId);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
this.records.set(record.attemptId, record);
|
|
243
|
+
seen.add(record.attemptId);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
logError(`Skipping the unreadable attempt record ${file}; remove it if the attempt is over`, error);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
for (const id of [...this.records.keys()]) if (!seen.has(id) && !protectedRecord(id)) this.records.delete(id);
|
|
249
|
+
})();
|
|
250
|
+
}
|
|
251
|
+
async record(attemptId) {
|
|
252
|
+
await this.load();
|
|
253
|
+
return this.records.get(attemptId);
|
|
254
|
+
}
|
|
255
|
+
async save(record) {
|
|
256
|
+
this.journalVersions.set(record.attemptId, (this.journalVersions.get(record.attemptId) ?? 0) + 1);
|
|
257
|
+
record.updatedAt = this.now();
|
|
258
|
+
this.records.set(record.attemptId, record);
|
|
259
|
+
await mkdir(this.attemptsDir, { recursive: true, mode: 448 });
|
|
260
|
+
const file = join(this.attemptsDir, `${record.attemptId}.json`), temp = `${file}.${randomBytes(4).toString("hex")}.tmp`;
|
|
261
|
+
try {
|
|
262
|
+
const handle = await open(temp, "wx", 384);
|
|
263
|
+
try {
|
|
264
|
+
await handle.writeFile(JSON.stringify(record));
|
|
265
|
+
await handle.sync();
|
|
266
|
+
} finally {
|
|
267
|
+
await handle.close();
|
|
268
|
+
}
|
|
269
|
+
await rename(temp, file);
|
|
270
|
+
} finally {
|
|
271
|
+
await rm(temp, { force: true }).catch(() => {
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
async remove(attemptId) {
|
|
276
|
+
this.journalVersions.set(attemptId, (this.journalVersions.get(attemptId) ?? 0) + 1);
|
|
277
|
+
this.records.delete(attemptId);
|
|
278
|
+
await rm(join(this.attemptsDir, `${attemptId}.json`), { force: true }).catch(() => void 0);
|
|
279
|
+
}
|
|
280
|
+
/** One operation at a time per attempt, so report numbers go out in order and a finish never races a publish. */
|
|
281
|
+
serial(attemptId, fn) {
|
|
282
|
+
const previous = this.chains.get(attemptId) ?? Promise.resolve(), next = previous.catch(() => void 0).then(fn);
|
|
283
|
+
this.chains.set(attemptId, next);
|
|
284
|
+
next.finally(() => {
|
|
285
|
+
if (this.chains.get(attemptId) === next) this.chains.delete(attemptId);
|
|
286
|
+
}).catch(() => void 0);
|
|
287
|
+
return next;
|
|
288
|
+
}
|
|
289
|
+
async attempts() {
|
|
290
|
+
await this.load();
|
|
291
|
+
return [...this.records.values()].sort((a, b) => b.updatedAt - a.updatedAt).map((record) => structuredClone(record));
|
|
292
|
+
}
|
|
293
|
+
attemptForRun(runId) {
|
|
294
|
+
for (const record of this.records.values()) if (record.runId === runId) return structuredClone(record);
|
|
295
|
+
return void 0;
|
|
296
|
+
}
|
|
297
|
+
/** The latest local attempt of a task, optionally among some states; what the stop/resume/hand-over routes act on. */
|
|
298
|
+
async attemptForTask(taskId, states) {
|
|
299
|
+
await this.load();
|
|
300
|
+
let found;
|
|
301
|
+
for (const record of this.records.values()) if (record.taskId === taskId && (!states || states.includes(record.state)) && (!found || record.updatedAt >= found.updatedAt)) found = record;
|
|
302
|
+
return found && structuredClone(found);
|
|
303
|
+
}
|
|
304
|
+
assertOpen() {
|
|
305
|
+
if (this.closed) throw new CollabError("code-flow is shutting down; team attempts cannot start now", 503);
|
|
306
|
+
}
|
|
307
|
+
// ---- Repositories and checkpoints on this computer
|
|
308
|
+
async repoAt(path, hint) {
|
|
309
|
+
if (typeof path !== "string" || !isAbsolute(path) || path.length > 4096) throw new CollabError(hint);
|
|
310
|
+
try {
|
|
311
|
+
return await realpath(await repoRoot(path));
|
|
312
|
+
} catch {
|
|
313
|
+
throw new CollabError(`${path} is not inside a Git repository on this computer`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
resolveRepo(task, explicit) {
|
|
317
|
+
const path = explicit ?? this.options.resolveRepo?.(task.projectId, task.repository);
|
|
318
|
+
if (!path) throw new CollabError(`Map repository ${task.repository} to a folder in Settings \u2192 Team, or choose the folder when starting`);
|
|
319
|
+
return this.repoAt(path, "Repository must be an absolute path");
|
|
320
|
+
}
|
|
321
|
+
/** Download, verify and import a checkpoint's bundle into `repo` under `refs/codeflow/team/<checkpoint id>`; idempotent, and HEAD/branches stay untouched. A bundle whose ref names another checkpoint is refused (409) rather than filed under the wrong id on this computer. */
|
|
322
|
+
async importById(repo, checkpointId) {
|
|
323
|
+
if (!CHECKPOINT_ID.test(checkpointId)) throw new CollabError("Send the checkpoint id as 32 lowercase hexadecimal characters");
|
|
324
|
+
await mkdir(tmpdir(), { recursive: true }).catch(() => void 0);
|
|
325
|
+
const file = join(tmpdir(), `codeflow-team-download-${checkpointId}-${randomBytes(6).toString("hex")}.bundle`);
|
|
326
|
+
try {
|
|
327
|
+
const manifest = await this.hub.downloadBundle(checkpointId, file), embedded = await embeddedCheckpointId(repo, file);
|
|
328
|
+
if (embedded !== checkpointId) throw new CollabError(`The downloaded bundle does not belong to checkpoint ${checkpointId}; it advertises refs/codeflow/team/${embedded}. Delete this checkpoint and create a new one from the computer that holds the work`, 409);
|
|
329
|
+
const { commit } = await importCheckpointBundle(repo, file, { id: checkpointId, commit: manifest.commit, baseCommit: manifest.baseCommit, size: manifest.size, sha256: manifest.sha256, ref: checkpointRef(checkpointId) });
|
|
330
|
+
return { manifest, commit };
|
|
331
|
+
} finally {
|
|
332
|
+
await rm(file, { force: true }).catch(() => void 0);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/** The checkpoint of what a run left on its branch. Its id is minted here, before bundling, and travels in the manifest: the bundle's only ref is `refs/codeflow/team/<id>` and the hub keeps that id as the checkpoint's, so the board, the branch `team/<task>/<id8>` and the refs on every importing machine agree. The bundle is kept under this runner's folder until the hub has its bytes, so a failed upload is retried, never rebuilt; a bundle kept for an earlier checkpoint of the attempt is dropped first, never left behind. */
|
|
336
|
+
async publishCheckpoint(record, run, task, head, branch, note, changedFiles) {
|
|
337
|
+
await this.dropKept(record);
|
|
338
|
+
const parentId = task.attempts.find((attempt) => attempt.id === record.attemptId)?.fromCheckpointId;
|
|
339
|
+
const parent = parentId ? task.checkpoints.find((checkpoint2) => checkpoint2.id === parentId) : void 0;
|
|
340
|
+
if (parentId && !parent) throw new CollabError("The checkpoint this attempt continued from is missing; restore its manifest before publishing", 409);
|
|
341
|
+
const baseCommit = parent?.baseCommit ?? record.base;
|
|
342
|
+
const share = (value, max, singleLine = false) => sharedText(value, max, singleLine, localPaths(run));
|
|
343
|
+
const checkpointId = newCheckpointId(), bundle = await createCheckpointBundle(run.repo, head, baseCommit, checkpointId), kept = join(this.bundlesDir, `${checkpointId}.bundle`);
|
|
344
|
+
await mkdir(this.bundlesDir, { recursive: true, mode: 448 });
|
|
345
|
+
try {
|
|
346
|
+
await rename(bundle.file, kept);
|
|
347
|
+
} catch {
|
|
348
|
+
await copyFile(bundle.file, kept, constants.COPYFILE_EXCL);
|
|
349
|
+
await rm(bundle.file, { force: true });
|
|
350
|
+
}
|
|
351
|
+
const manifest = { id: checkpointId, commit: head, baseCommit, branch: share(branch, MAX.branch, true), size: bundle.size, sha256: bundle.sha256, note: share(note, MAX.note), requirements: share(`${task.title}
|
|
352
|
+
|
|
353
|
+
## Goal
|
|
354
|
+
${task.goal}
|
|
355
|
+
|
|
356
|
+
## Acceptance criteria
|
|
357
|
+
${task.acceptance}`, MAX.requirements), decisions: task.decisions.slice(0, MAX.decisions).map((decision) => share(decision.text, MAX.decision)).filter(Boolean), remaining: tail(share(run.summary, Number.MAX_SAFE_INTEGER), MAX.remaining), changedFiles: changedFiles.slice(0, MAX.changedFiles).map((path) => share(path, MAX.path, true)).filter(Boolean) };
|
|
358
|
+
if (run.lastCheck) manifest.testResults = { ok: run.lastCheck.ok, command: share(run.lastCheck.command, MAX.command, true) || "check", at: count(run.lastCheck.at) ? run.lastCheck.at : this.now() };
|
|
359
|
+
let checkpoint;
|
|
360
|
+
try {
|
|
361
|
+
checkpoint = await this.hub.addCheckpoint(record.attemptId, manifest);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
await rm(kept, { force: true }).catch(() => void 0);
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
if (checkpoint.id !== checkpointId) {
|
|
367
|
+
await rm(kept, { force: true }).catch(() => void 0);
|
|
368
|
+
throw new CollabError(`The team server named the checkpoint ${checkpoint.id} instead of ${checkpointId}, so its bundle could never be imported; update the team server to a code-flow that keeps checkpoint ids`, 502);
|
|
369
|
+
}
|
|
370
|
+
record.checkpointId = checkpoint.id;
|
|
371
|
+
record.pendingFinish = { state: run.status === "running" ? "finished" : mapRunStatus(run).state, error: "", at: this.now(), tries: 0, ...record.pendingFinish, bundleFile: kept };
|
|
372
|
+
await this.save(record);
|
|
373
|
+
await this.uploadKept(record, checkpoint);
|
|
374
|
+
return checkpoint;
|
|
375
|
+
}
|
|
376
|
+
/** Uploads the kept bundle of `checkpoint`. The hub refuses a repeated upload before reading the body and drops the socket, so a retry of an upload it already completed can end in a write error rather than its 409: the checkpoint on the hub, not the answer, says whether the bytes arrived. */
|
|
377
|
+
async uploadKept(record, checkpoint) {
|
|
378
|
+
const kept = record.pendingFinish?.bundleFile;
|
|
379
|
+
if (!kept) return;
|
|
380
|
+
try {
|
|
381
|
+
await this.hub.uploadBundle(checkpoint.id, kept, { size: checkpoint.size, sha256: checkpoint.sha256 });
|
|
382
|
+
} catch (error) {
|
|
383
|
+
const already = error instanceof CollabError && error.status === 409 && /already uploaded/.test(error.message);
|
|
384
|
+
const stored = already ? void 0 : await this.hub.task(record.taskId).then((task) => task.checkpoints.find((entry) => entry.id === checkpoint.id)).catch(() => void 0);
|
|
385
|
+
if (!already && !(stored?.bundle === "available" && stored.sha256 === checkpoint.sha256)) throw error;
|
|
386
|
+
}
|
|
387
|
+
await this.dropKept(record);
|
|
388
|
+
await this.save(record);
|
|
389
|
+
}
|
|
390
|
+
/** Removes the bundle kept for a checkpoint this computer no longer needs to upload (the hub has it, it was replaced, the attempt is over); the caller saves the record. */
|
|
391
|
+
async dropKept(record) {
|
|
392
|
+
const kept = record.pendingFinish?.bundleFile;
|
|
393
|
+
if (!kept) return;
|
|
394
|
+
await rm(kept, { force: true }).catch(() => void 0);
|
|
395
|
+
delete record.pendingFinish.bundleFile;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* The hub's checkpoint of `head` for this attempt, bytes included: the one already recorded when it still names this
|
|
399
|
+
* head (its kept bundle uploaded when the hub lacks the bytes, dropped when the hub has them), else a new one. A
|
|
400
|
+
* checkpoint deleted on the hub, or left pending without a bundle here, is replaced. Runs under serial(record.attemptId).
|
|
401
|
+
*/
|
|
402
|
+
async ensureCheckpoint(record, run, task, head, branch, note, files) {
|
|
403
|
+
const attempt = task.attempts.find((entry) => entry.id === record.attemptId), id = record.checkpointId ?? attempt?.checkpointId, existing = id ? task.checkpoints.find((entry) => entry.id === id) : void 0;
|
|
404
|
+
if (existing && existing.commit === head && existing.bundle !== "deleted") {
|
|
405
|
+
if (existing.bundle === "available") {
|
|
406
|
+
await this.dropKept(record);
|
|
407
|
+
await this.save(record);
|
|
408
|
+
return existing.id;
|
|
409
|
+
}
|
|
410
|
+
if (record.pendingFinish?.bundleFile) {
|
|
411
|
+
try {
|
|
412
|
+
await this.uploadKept(record, existing);
|
|
413
|
+
return existing.id;
|
|
414
|
+
} catch (error) {
|
|
415
|
+
if (!(error instanceof CollabError && error.status === 409 && /deleted/.test(error.message))) throw error;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return (await this.publishCheckpoint(record, run, task, head, branch, note, files)).id;
|
|
420
|
+
}
|
|
421
|
+
async changedFilesOf(runId, branch) {
|
|
422
|
+
try {
|
|
423
|
+
const coverage = await this.manager.reviewCoverage(runId, branch);
|
|
424
|
+
return { head: coverage.head, files: coverage.files };
|
|
425
|
+
} catch {
|
|
426
|
+
return void 0;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
// ---- Starting, attaching, publishing
|
|
430
|
+
async startAttempt(taskId, input) {
|
|
431
|
+
this.assertOpen();
|
|
432
|
+
const hub = this.hub;
|
|
433
|
+
let task = await hub.task(taskId);
|
|
434
|
+
const pattern = typeof input.pattern === "string" ? this.options.findPattern(input.pattern) : input.pattern;
|
|
435
|
+
if (!pattern) throw new CollabError("Unknown pattern");
|
|
436
|
+
let busy = busyAttempt(task);
|
|
437
|
+
if (busy && busy.runnerId === hub.runnerId && await this.settleStale(busy.id)) {
|
|
438
|
+
task = await hub.task(taskId);
|
|
439
|
+
busy = busyAttempt(task);
|
|
440
|
+
}
|
|
441
|
+
if (busy) throw new CollabError(`Stop the attempt running on ${busy.runnerName} before starting another`, 409);
|
|
442
|
+
if (task.handoff?.state === "offered") throw new CollabError("Accept, decline or cancel the offered hand-over before starting an attempt", 409);
|
|
443
|
+
const repo = await this.resolveRepo(task, input.repo);
|
|
444
|
+
let checkpoint, base;
|
|
445
|
+
if (input.fromCheckpointId !== void 0) {
|
|
446
|
+
checkpoint = task.checkpoints.find((entry) => entry.id === input.fromCheckpointId);
|
|
447
|
+
if (!checkpoint) throw new CollabError("Checkpoint not found", 404);
|
|
448
|
+
if (checkpoint.bundle !== "available") throw new CollabError("The checkpoint's bundle is not on the team server; choose another checkpoint or start from the base branch", 409);
|
|
449
|
+
base = (await this.importById(repo, checkpoint.id)).commit;
|
|
450
|
+
task = await hub.task(taskId);
|
|
451
|
+
busy = busyAttempt(task);
|
|
452
|
+
if (busy) throw new CollabError(`Stop the attempt running on ${busy.runnerName} before starting another`, 409);
|
|
453
|
+
if (task.handoff?.state === "offered") throw new CollabError("Accept, decline or cancel the offered hand-over before starting an attempt", 409);
|
|
454
|
+
} else {
|
|
455
|
+
const ref = task.baseRef ?? "HEAD";
|
|
456
|
+
try {
|
|
457
|
+
base = await resolveCommit(repo, ref);
|
|
458
|
+
} catch {
|
|
459
|
+
throw new CollabError(`Base ${ref} does not exist in ${repo}; fetch it or change the task's base branch`);
|
|
460
|
+
}
|
|
461
|
+
await assertCheckpointable(repo, base);
|
|
462
|
+
}
|
|
463
|
+
const record = { attemptId: newAttemptId(), taskId: task.id, repo, base, state: "claiming", lastSeq: 0, lastAckedSeq: 0, deliveredInstructionIds: [], updatedAt: this.now() };
|
|
464
|
+
if (hub.url) record.hubUrl = hub.url;
|
|
465
|
+
this.starting.add(record.attemptId);
|
|
466
|
+
try {
|
|
467
|
+
await this.save(record);
|
|
468
|
+
let claimed;
|
|
469
|
+
try {
|
|
470
|
+
await this.options.beforeStart?.();
|
|
471
|
+
claimed = await hub.claimAttempt(task.id, { attemptId: record.attemptId, runnerId: hub.runnerId, runnerName: hub.runnerName, baseCommit: base, ...checkpoint ? { fromCheckpointId: checkpoint.id } : {}, revision: task.revision });
|
|
472
|
+
} catch (error) {
|
|
473
|
+
if (error instanceof CollabError && error.status < 500) await this.remove(record.attemptId);
|
|
474
|
+
throw error;
|
|
475
|
+
}
|
|
476
|
+
record.state = "claimed";
|
|
477
|
+
await this.save(record);
|
|
478
|
+
const projectName = await hub.board(task.projectId).then((board) => board.projects.find((project) => project.id === task.projectId)?.name).catch(() => void 0);
|
|
479
|
+
const handover = checkpoint ? [...claimed.handoffs].reverse().find((entry) => entry.state === "accepted" && entry.checkpointId === checkpoint.id) : void 0, handoverNote = handover?.note.trim() ?? "";
|
|
480
|
+
const context = checkpoint && handoverNote && handoverNote !== checkpoint.note.trim() ? { ...checkpoint, note: clean([checkpoint.note.trim(), `Hand-over note from ${clean(handover.from.displayName, MAX.displayName, true) || "a teammate"}: ${handoverNote}`].filter(Boolean).join("\n\n"), MAX.note) } : checkpoint;
|
|
481
|
+
const prompt = taskPrompt(claimed, { projectName, checkpoint: context });
|
|
482
|
+
let run;
|
|
483
|
+
try {
|
|
484
|
+
await this.options.beforeStart?.();
|
|
485
|
+
run = await this.manager.start({ checkpointOnFinish: true, pattern, task: prompt, repo, baseRef: base, roles: input.roles, options: {}, agentSettings: input.agentSettings, roleSettings: input.roleSettings, trust: input.trust, loop: input.loop, project: this.options.projectFor?.(repo), beforeDispatch: async (pending) => {
|
|
486
|
+
await this.options.beforeStart?.();
|
|
487
|
+
record.runId = pending.id;
|
|
488
|
+
await this.save(record);
|
|
489
|
+
} });
|
|
490
|
+
} catch (error) {
|
|
491
|
+
const reason = sharedText(`The run could not start on this computer: ${message(error)}`, MAX.reason, false, localPaths({ repo }));
|
|
492
|
+
record.state = "failed";
|
|
493
|
+
record.pendingFinish = { state: "failed", reason, error: "", at: this.now(), tries: 0 };
|
|
494
|
+
await this.save(record);
|
|
495
|
+
await this.withoutRun(record, "failed", reason).catch((failure) => logError("The team server could not be told that the run failed to start; the recovery pass retries it", failure));
|
|
496
|
+
throw asCollab(error);
|
|
497
|
+
}
|
|
498
|
+
record.runId = run.id;
|
|
499
|
+
record.state = "running";
|
|
500
|
+
await this.save(record);
|
|
501
|
+
await this.serial(record.attemptId, () => this.report(record, { runId: run.id, runStatus: "running", updatedAt: this.now() }));
|
|
502
|
+
this.attach(record.attemptId, run.id);
|
|
503
|
+
return { attemptId: record.attemptId, runId: run.id };
|
|
504
|
+
} finally {
|
|
505
|
+
this.starting.delete(record.attemptId);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
/** Journals the next report number before it goes out, so a crash can never reuse one; a failed send is not retried (the next snapshot supersedes it). */
|
|
509
|
+
async report(record, body) {
|
|
510
|
+
const seq = ++record.lastSeq;
|
|
511
|
+
if (record.state === "claimed" || record.state === "paused" || record.state === "stopped" || record.state === "unknown") record.state = "running";
|
|
512
|
+
await this.save(record);
|
|
513
|
+
try {
|
|
514
|
+
await this.hub.progress(record.attemptId, { seq, ...body });
|
|
515
|
+
record.lastAckedSeq = seq;
|
|
516
|
+
await this.save(record);
|
|
517
|
+
return void 0;
|
|
518
|
+
} catch (error) {
|
|
519
|
+
logError(`Progress report ${seq} for attempt ${record.attemptId} did not reach the team server`, error);
|
|
520
|
+
return error instanceof CollabError ? error.status : void 0;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/** Losing execution authority stops the local agent as well as its reports. The hub keeps unknown attempts reserved until stopping is confirmed. */
|
|
524
|
+
hubEnded(live, status, what) {
|
|
525
|
+
if (status === void 0 || !HUB_FINAL.has(status)) return false;
|
|
526
|
+
logError(`The team server no longer accepts ${what} for attempt ${live.attemptId} (HTTP ${status}); stopping run ${live.runId}`);
|
|
527
|
+
this.detach(live);
|
|
528
|
+
this.manager.cancel(live.runId);
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
/** Follows a run already started for an attempt: bounded snapshots at most every 2 s (at once when it ends), the branch head after each step, heartbeats every 20 s, and the finish when the run ends. */
|
|
532
|
+
attach(attemptId, runId) {
|
|
533
|
+
if (this.closed || this.active.has(attemptId)) return;
|
|
534
|
+
const run = this.manager.get(runId);
|
|
535
|
+
if (!run) {
|
|
536
|
+
void this.serial(attemptId, async () => {
|
|
537
|
+
const record = await this.record(attemptId);
|
|
538
|
+
if (record) await this.lost(record, `Run ${runId} is missing from this computer`);
|
|
539
|
+
});
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
const live = { attemptId, runId, unsubscribe: () => {
|
|
543
|
+
}, dirty: true, ended: false };
|
|
544
|
+
this.active.set(attemptId, live);
|
|
545
|
+
live.unsubscribe = this.manager.subscribe(runId, (event) => {
|
|
546
|
+
if (live.ended) return;
|
|
547
|
+
if (event.type === "step" && event.step.status !== "running") void this.refreshHead(live);
|
|
548
|
+
if (event.type === "run" || event.type === "step") this.schedulePublish(live);
|
|
549
|
+
if (event.type === "run" && event.run.status !== "running") this.terminal(live);
|
|
550
|
+
});
|
|
551
|
+
live.heartbeat = setInterval(() => void this.heartbeatNow(live), HEARTBEAT_INTERVAL_MS);
|
|
552
|
+
live.heartbeat.unref?.();
|
|
553
|
+
void this.refreshHead(live);
|
|
554
|
+
this.schedulePublish(live, true);
|
|
555
|
+
if (run.status !== "running") this.terminal(live);
|
|
556
|
+
}
|
|
557
|
+
refreshHead(live) {
|
|
558
|
+
const run = this.manager.get(live.runId), branch = run?.worktrees[0]?.branch;
|
|
559
|
+
if (!run || !branch) return Promise.resolve();
|
|
560
|
+
const work = this.changedFilesOf(run.id, branch).then((result) => {
|
|
561
|
+
if (result) {
|
|
562
|
+
live.head = result.head;
|
|
563
|
+
live.changedFiles = result.files;
|
|
564
|
+
live.dirty = true;
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
live.refreshing = work;
|
|
568
|
+
return work;
|
|
569
|
+
}
|
|
570
|
+
schedulePublish(live, immediate = false) {
|
|
571
|
+
live.dirty = true;
|
|
572
|
+
if (live.ended) return;
|
|
573
|
+
if (immediate) {
|
|
574
|
+
if (live.publishTimer) {
|
|
575
|
+
clearTimeout(live.publishTimer);
|
|
576
|
+
live.publishTimer = void 0;
|
|
577
|
+
}
|
|
578
|
+
void this.publish(live);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
if (live.publishTimer) return;
|
|
582
|
+
live.publishTimer = setTimeout(() => {
|
|
583
|
+
live.publishTimer = void 0;
|
|
584
|
+
void this.publish(live);
|
|
585
|
+
}, PUBLISH_INTERVAL_MS);
|
|
586
|
+
live.publishTimer.unref?.();
|
|
587
|
+
}
|
|
588
|
+
publish(live) {
|
|
589
|
+
return this.serial(live.attemptId, async () => {
|
|
590
|
+
if (!live.dirty || this.closed) return;
|
|
591
|
+
live.dirty = false;
|
|
592
|
+
await live.refreshing?.catch(() => void 0);
|
|
593
|
+
const record = await this.record(live.attemptId), run = this.manager.get(live.runId);
|
|
594
|
+
if (!record || !run || run.status !== "running" || record.state === "finished" || record.state === "failed") return;
|
|
595
|
+
this.hubEnded(live, await this.report(record, { ...progressFromRun(run, { head: live.head, changedFiles: live.changedFiles }, this.now()), runId: live.runId }), "progress reports");
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
detach(live) {
|
|
599
|
+
live.ended = true;
|
|
600
|
+
if (live.publishTimer) clearTimeout(live.publishTimer);
|
|
601
|
+
if (live.heartbeat) clearInterval(live.heartbeat);
|
|
602
|
+
live.unsubscribe();
|
|
603
|
+
if (this.active.get(live.attemptId) === live) this.active.delete(live.attemptId);
|
|
604
|
+
}
|
|
605
|
+
terminal(live) {
|
|
606
|
+
if (live.ended) return;
|
|
607
|
+
this.detach(live);
|
|
608
|
+
void this.serial(live.attemptId, async () => {
|
|
609
|
+
const record = await this.record(live.attemptId);
|
|
610
|
+
if (!record || this.closed) return;
|
|
611
|
+
await this.finishNow(record).catch((error) => logError(`Attempt ${record.attemptId} could not be finished on the team server; it is retried by the recovery pass`, error));
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
/** Delivers approved instructions once each: the note reaches the agent, the id is journaled, then the hub is told; a crash in between is caught by the journal, not by a second note. */
|
|
615
|
+
heartbeatNow(live) {
|
|
616
|
+
return this.serial(live.attemptId, async () => {
|
|
617
|
+
if (live.ended || this.closed) return [];
|
|
618
|
+
const record = await this.record(live.attemptId);
|
|
619
|
+
if (!record) return [];
|
|
620
|
+
let result;
|
|
621
|
+
try {
|
|
622
|
+
result = await this.hub.heartbeat(record.attemptId);
|
|
623
|
+
} catch (error) {
|
|
624
|
+
if (!this.hubEnded(live, error instanceof CollabError ? error.status : void 0, "heartbeats")) logError(`Heartbeat for attempt ${record.attemptId} did not reach the team server`, error);
|
|
625
|
+
return [];
|
|
626
|
+
}
|
|
627
|
+
const delivered = [];
|
|
628
|
+
for (const instruction of result.instructions) {
|
|
629
|
+
if (instruction.state !== "approved") continue;
|
|
630
|
+
if (!record.deliveredInstructionIds.includes(instruction.id)) {
|
|
631
|
+
try {
|
|
632
|
+
this.manager.addNote(live.runId, instructionNote(instruction));
|
|
633
|
+
} catch (error) {
|
|
634
|
+
logError(`Instruction ${instruction.id} could not be handed to the run ${live.runId}`, error);
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
record.deliveredInstructionIds.push(instruction.id);
|
|
638
|
+
if (record.deliveredInstructionIds.length > MAX_DELIVERED) record.deliveredInstructionIds.splice(0, record.deliveredInstructionIds.length - MAX_DELIVERED);
|
|
639
|
+
await this.save(record);
|
|
640
|
+
delivered.push(instruction.id);
|
|
641
|
+
}
|
|
642
|
+
try {
|
|
643
|
+
await this.hub.markInstructionDelivered(record.taskId, instruction.id, record.attemptId);
|
|
644
|
+
} catch (error) {
|
|
645
|
+
logError(`Instruction ${instruction.id} was delivered but the team server could not be told`, error);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
return delivered;
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
/** A heartbeat on demand (the timer sends one every 20 s): returns the ids of instructions handed to the agent this time. */
|
|
652
|
+
async heartbeat(attemptId) {
|
|
653
|
+
const live = this.active.get(attemptId);
|
|
654
|
+
if (!live) throw new CollabError("This attempt is not running on this computer", 409);
|
|
655
|
+
return this.heartbeatNow(live);
|
|
656
|
+
}
|
|
657
|
+
// ---- Finishing
|
|
658
|
+
async finishAttempt(attemptId) {
|
|
659
|
+
const record = await this.record(attemptId);
|
|
660
|
+
if (!record) throw new CollabError("Attempt not found on this computer", 404);
|
|
661
|
+
return this.serial(attemptId, () => this.finishNow(record));
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* The terminal snapshot (with the branch head and changed files) goes out first, so the hub's attempt knows its run even
|
|
665
|
+
* when the finish is the first thing this computer managed to send; then the checkpoint, then the finish. Idempotent: an
|
|
666
|
+
* attempt whose publish failed keeps `pendingFinish` and is finished again with the same checkpoint on the next call. A
|
|
667
|
+
* checkpoint the hub or Git would refuse again (a tree checkpoints cannot carry, a bundle over 64 MiB, a manifest the hub
|
|
668
|
+
* rejects) does not hold the attempt open: the task is released with the reason, and the work stays on this computer's branch.
|
|
669
|
+
*/
|
|
670
|
+
async finishNow(record) {
|
|
671
|
+
const run = record.runId ? this.manager.get(record.runId) : void 0;
|
|
672
|
+
if (!run && !record.runId && record.pendingFinish?.state === "failed") return this.withoutRun(record, "failed", record.pendingFinish.reason ?? "The run could not start on this computer");
|
|
673
|
+
if (!run) return this.lost(record, record.runId ? `Run ${record.runId} is missing from this computer` : "The run never started on this computer; start a new attempt");
|
|
674
|
+
if (run.status === "running") throw new CollabError("The run is still running; stop it before finishing the attempt", 409);
|
|
675
|
+
const outcome = mapRunStatus(run), branch = run.worktrees[0]?.branch, coverage = branch ? await this.changedFilesOf(run.id, branch) : void 0, head = coverage?.head ?? (branch ? await resolveCommit(run.repo, `refs/heads/${branch}`).catch(() => void 0) : void 0);
|
|
676
|
+
if (record.state !== "finished" && record.state !== "failed" && !record.pendingFinish) await this.report(record, { ...progressFromRun(run, { head, changedFiles: coverage?.files }, this.now()), runId: run.id });
|
|
677
|
+
const pending = { ...record.pendingFinish, state: outcome.state, error: record.pendingFinish?.error ?? "", at: this.now(), tries: record.pendingFinish?.tries ?? 0 };
|
|
678
|
+
if (head) pending.head = head;
|
|
679
|
+
else delete pending.head;
|
|
680
|
+
const summary = sharedText(run.summary, MAX.summary, false, localPaths(run));
|
|
681
|
+
if (summary.trim()) pending.summary = summary;
|
|
682
|
+
else delete pending.summary;
|
|
683
|
+
if (outcome.reason) pending.reason = outcome.reason;
|
|
684
|
+
else delete pending.reason;
|
|
685
|
+
record.pendingFinish = pending;
|
|
686
|
+
const finish = { state: pending.state, ...pending.head ? { head: pending.head } : {}, ...pending.summary ? { summary: pending.summary } : {}, ...pending.reason ? { reason: pending.reason } : {} };
|
|
687
|
+
let finishing = false;
|
|
688
|
+
try {
|
|
689
|
+
if (head && branch && head !== record.base) {
|
|
690
|
+
const task = await this.hub.task(record.taskId);
|
|
691
|
+
try {
|
|
692
|
+
await this.ensureCheckpoint(record, run, task, head, branch, "", coverage?.files ?? []);
|
|
693
|
+
} catch (error) {
|
|
694
|
+
if (!(error instanceof CollabError && error.status >= 400 && error.status < 500)) throw error;
|
|
695
|
+
await this.dropKept(record);
|
|
696
|
+
finish.state = pending.state === "finished" ? "failed" : pending.state;
|
|
697
|
+
finish.reason = sharedText(`No checkpoint was published: ${error.message}${pending.reason ? ` (${pending.reason})` : ""}`, MAX.reason, false, localPaths(run));
|
|
698
|
+
logError(`Attempt ${record.attemptId} ends on the team server without a checkpoint; its work stays on branch ${branch} of ${run.repo}`, error);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
finishing = true;
|
|
702
|
+
await this.hub.finish(record.attemptId, finish);
|
|
703
|
+
await this.dropKept(record);
|
|
704
|
+
delete record.pendingFinish;
|
|
705
|
+
record.state = finish.state;
|
|
706
|
+
await this.save(record);
|
|
707
|
+
return structuredClone(record);
|
|
708
|
+
} catch (error) {
|
|
709
|
+
const status = error instanceof CollabError ? error.status : void 0;
|
|
710
|
+
if (status === 401 || finishing && status !== void 0 && HUB_FINAL.has(status)) {
|
|
711
|
+
logError(`The team server closed attempt ${record.attemptId} on its own (${message(error)}); this computer records the run's outcome and stops retrying`);
|
|
712
|
+
await this.dropKept(record);
|
|
713
|
+
delete record.pendingFinish;
|
|
714
|
+
record.state = pending.state;
|
|
715
|
+
await this.save(record);
|
|
716
|
+
return structuredClone(record);
|
|
717
|
+
}
|
|
718
|
+
const current = record.pendingFinish ?? pending;
|
|
719
|
+
current.error = clean(message(error), 1e3);
|
|
720
|
+
current.tries++;
|
|
721
|
+
current.at = this.now();
|
|
722
|
+
record.pendingFinish = current;
|
|
723
|
+
record.state = pending.state;
|
|
724
|
+
if (current.tries >= MAX_FINISH_TRIES) {
|
|
725
|
+
logError(`Attempt ${record.attemptId} could not be finished on the team server in ${current.tries} tries (${current.error}); this computer keeps the run's outcome and gives up`);
|
|
726
|
+
await this.dropKept(record);
|
|
727
|
+
delete record.pendingFinish;
|
|
728
|
+
await this.save(record);
|
|
729
|
+
return structuredClone(record);
|
|
730
|
+
}
|
|
731
|
+
await this.save(record);
|
|
732
|
+
throw asCollab(error, 502);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
/** A missing run cannot prove execution stopped: leave the hub reservation unknown for explicit recovery. */
|
|
736
|
+
lost(record, reason) {
|
|
737
|
+
return this.withoutRun(record, "unknown", reason);
|
|
738
|
+
}
|
|
739
|
+
/** Publishes the finish of an attempt that has no run to report on: `unknown` when the run is gone, `failed` when it never started. The hub's final word and the retry bound apply as in finishNow. */
|
|
740
|
+
async withoutRun(record, state, reason) {
|
|
741
|
+
await this.dropKept(record);
|
|
742
|
+
const pending = { state, reason: sharedText(reason, MAX.reason, false, localPaths({ repo: record.repo })), error: record.pendingFinish?.error ?? "", at: this.now(), tries: record.pendingFinish?.tries ?? 0 };
|
|
743
|
+
try {
|
|
744
|
+
await this.hub.finish(record.attemptId, { state, reason: pending.reason });
|
|
745
|
+
delete record.pendingFinish;
|
|
746
|
+
record.state = state;
|
|
747
|
+
await this.save(record);
|
|
748
|
+
return structuredClone(record);
|
|
749
|
+
} catch (error) {
|
|
750
|
+
const status = error instanceof CollabError ? error.status : void 0;
|
|
751
|
+
record.state = state;
|
|
752
|
+
if (status !== void 0 && HUB_FINAL.has(status)) {
|
|
753
|
+
logError(`The team server closed attempt ${record.attemptId} on its own (${message(error)}); this computer records it as ${state} and stops retrying`);
|
|
754
|
+
delete record.pendingFinish;
|
|
755
|
+
await this.save(record);
|
|
756
|
+
return structuredClone(record);
|
|
757
|
+
}
|
|
758
|
+
pending.error = clean(message(error), 1e3);
|
|
759
|
+
pending.tries++;
|
|
760
|
+
record.pendingFinish = pending;
|
|
761
|
+
if (pending.tries >= MAX_FINISH_TRIES) {
|
|
762
|
+
logError(`Attempt ${record.attemptId} could not be reported ${state} to the team server in ${pending.tries} tries (${pending.error}); this computer gives up`);
|
|
763
|
+
delete record.pendingFinish;
|
|
764
|
+
await this.save(record);
|
|
765
|
+
return structuredClone(record);
|
|
766
|
+
}
|
|
767
|
+
await this.save(record);
|
|
768
|
+
throw asCollab(error, 502);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
// ---- Stop, resume, hand over, accept, fetch
|
|
772
|
+
/** `manager.cancel` is the engine's stop; the run's terminal snapshot then finishes the attempt as paused, so it can be resumed from here. */
|
|
773
|
+
async stopAttempt(attemptId) {
|
|
774
|
+
const record = await this.record(attemptId);
|
|
775
|
+
if (!record) throw new CollabError("Attempt not found on this computer", 404);
|
|
776
|
+
if (!record.runId) throw new CollabError("This attempt has no run on this computer yet", 409);
|
|
777
|
+
const run = this.manager.get(record.runId);
|
|
778
|
+
if (!run || run.status !== "running") throw new CollabError("The run is not running; there is nothing to stop", 409);
|
|
779
|
+
if (!this.active.has(attemptId)) this.attach(attemptId, record.runId);
|
|
780
|
+
const cancelled = this.manager.cancel(record.runId);
|
|
781
|
+
return { attemptId, runId: record.runId, cancelled };
|
|
782
|
+
}
|
|
783
|
+
/** Resumes the paused run of an attempt the hub still considers this computer's: one superseded by a hand-over or a reassignment, or a task busy with another attempt, must not get its agent back here, where every report would be refused. */
|
|
784
|
+
async resumeAttempt(attemptId) {
|
|
785
|
+
this.assertOpen();
|
|
786
|
+
const record = await this.record(attemptId);
|
|
787
|
+
if (!record) throw new CollabError("Attempt not found on this computer", 404);
|
|
788
|
+
if (!record.runId) throw new CollabError("This attempt has no run on this computer to resume", 409);
|
|
789
|
+
if (record.state === "finished" || record.state === "failed") throw new CollabError(`This attempt already ${record.state}; start a new attempt instead`, 409);
|
|
790
|
+
const run = this.manager.get(record.runId);
|
|
791
|
+
if (!run) throw new CollabError(`Run ${record.runId} is missing from this computer`, 404);
|
|
792
|
+
if (run.status === "running") throw new CollabError("The run is already running", 409);
|
|
793
|
+
return this.serial(attemptId, async () => {
|
|
794
|
+
const task = await this.hub.task(record.taskId), attempt = task.attempts.find((entry) => entry.id === attemptId);
|
|
795
|
+
if (!attempt) throw new CollabError("The team server no longer lists this attempt; start a new attempt instead", 409);
|
|
796
|
+
if (attempt.state === "superseded") throw new CollabError("This attempt was superseded by a change of owner; start a new attempt from the latest checkpoint instead", 409);
|
|
797
|
+
if (attempt.state === "finished" || attempt.state === "failed") throw new CollabError(`This attempt already ended as ${attempt.state} on the team server; start a new attempt instead`, 409);
|
|
798
|
+
if (task.status === "done") throw new CollabError("Move the task out of Done before resuming the attempt", 409);
|
|
799
|
+
const busy = busyAttempt(task);
|
|
800
|
+
if (busy && busy.id !== attemptId) throw new CollabError(`Stop the attempt running on ${busy.runnerName} before resuming this one`, 409);
|
|
801
|
+
if (task.handoff?.state === "offered") throw new CollabError("Accept, decline or cancel the offered hand-over before resuming an attempt", 409);
|
|
802
|
+
if (task.owner?.userId !== attempt.user.userId) throw new CollabError(ownerChangedMessage(task), 409);
|
|
803
|
+
const previousState = record.state, seq = ++record.lastSeq;
|
|
804
|
+
record.state = "claimed";
|
|
805
|
+
await this.save(record);
|
|
806
|
+
try {
|
|
807
|
+
await this.hub.progress(record.attemptId, { seq, runId: record.runId, runStatus: "running", updatedAt: this.now() });
|
|
808
|
+
} catch (error) {
|
|
809
|
+
if (error instanceof CollabError && error.status < 500) {
|
|
810
|
+
record.state = previousState;
|
|
811
|
+
await this.save(record);
|
|
812
|
+
}
|
|
813
|
+
throw error;
|
|
814
|
+
}
|
|
815
|
+
record.lastAckedSeq = seq;
|
|
816
|
+
record.state = "claimed";
|
|
817
|
+
await this.dropKept(record);
|
|
818
|
+
delete record.pendingFinish;
|
|
819
|
+
await this.save(record);
|
|
820
|
+
try {
|
|
821
|
+
await this.manager.resume(record.runId);
|
|
822
|
+
} catch (error) {
|
|
823
|
+
await this.finishNow(record);
|
|
824
|
+
throw asCollab(error, 409);
|
|
825
|
+
}
|
|
826
|
+
record.state = "running";
|
|
827
|
+
await this.save(record);
|
|
828
|
+
this.attach(attemptId, record.runId);
|
|
829
|
+
return { attemptId, runId: record.runId };
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
/** Offers the task at a checkpoint of this computer's work: the attempt's existing checkpoint when it still matches the branch, else a new one (committing uncommitted changes first only when asked). Only the task's current attempt can be checkpointed: after a hand-over round trip this computer's latest record may be a superseded attempt, whose checkpoint is older than the task's latest. */
|
|
833
|
+
async handOver(taskId, input) {
|
|
834
|
+
const hub = this.hub, task = await hub.task(taskId), busy = busyAttempt(task);
|
|
835
|
+
if (busy) throw new CollabError(`Stop the attempt running on ${busy.runnerName} before handing the task over`, 409);
|
|
836
|
+
if (task.handoff?.state === "offered") throw new CollabError(`A hand-over to ${task.handoff.to.displayName} is already offered; cancel it before offering another`, 409);
|
|
837
|
+
const record = await this.attemptForTask(task.id);
|
|
838
|
+
let checkpointId;
|
|
839
|
+
const run = record?.runId ? this.manager.get(record.runId) : void 0, worktree = run?.worktrees[0], attempt = record ? task.attempts.find((entry) => entry.id === record.attemptId) : void 0;
|
|
840
|
+
const current = attempt !== void 0 && attempt.state !== "superseded" && [...task.attempts].reverse().find((entry) => entry.state !== "superseded")?.id === attempt.id;
|
|
841
|
+
if (record && run && worktree && attempt && current) {
|
|
842
|
+
if (run.status === "running") throw new CollabError("Stop the run before handing the task over", 409);
|
|
843
|
+
let head = await resolveCommit(run.repo, `refs/heads/${worktree.branch}`).catch(() => void 0);
|
|
844
|
+
const checkout = await assertWorktree(run.repo, { path: worktree.path, branch: worktree.branch }).catch(() => void 0), dirty = checkout ? await uncommittedCount(checkout).catch(() => 0) : 0;
|
|
845
|
+
if (dirty > 0) {
|
|
846
|
+
if (!input.includeUncommitted) throw new CollabError("Commit, discard, or include uncommitted changes first", 409);
|
|
847
|
+
head = await commitAll(run.repo, { name: worktree.name, path: worktree.path, branch: worktree.branch, base: run.baseRef }, "Hand-over checkpoint") ?? head;
|
|
848
|
+
}
|
|
849
|
+
if (head) {
|
|
850
|
+
const files = (await this.changedFilesOf(run.id, worktree.branch))?.files ?? [];
|
|
851
|
+
checkpointId = await this.serial(record.attemptId, async () => {
|
|
852
|
+
const live = this.records.get(record.attemptId) ?? record;
|
|
853
|
+
try {
|
|
854
|
+
const id = await this.ensureCheckpoint(live, run, task, head, worktree.branch, input.note ?? "", files);
|
|
855
|
+
if (live.pendingFinish && !live.pendingFinish.bundleFile && live.pendingFinish.state === attempt.state) delete live.pendingFinish;
|
|
856
|
+
await this.save(live);
|
|
857
|
+
return id;
|
|
858
|
+
} catch (error) {
|
|
859
|
+
if (live.pendingFinish?.bundleFile) {
|
|
860
|
+
live.pendingFinish.error = clean(message(error), 1e3);
|
|
861
|
+
live.pendingFinish.tries++;
|
|
862
|
+
live.pendingFinish.at = this.now();
|
|
863
|
+
await this.save(live);
|
|
864
|
+
}
|
|
865
|
+
throw error;
|
|
866
|
+
}
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
} else if (record && run && worktree && attempt) {
|
|
870
|
+
const head = await resolveCommit(run.repo, `refs/heads/${worktree.branch}`).catch(() => void 0), recorded = (attempt.checkpointId ? task.checkpoints.find((entry) => entry.id === attempt.checkpointId)?.commit : void 0) ?? attempt.head ?? record.base;
|
|
871
|
+
const checkout = input.includeUncommitted ? await assertWorktree(run.repo, { path: worktree.path, branch: worktree.branch }).catch(() => void 0) : void 0, dirty = checkout ? await uncommittedCount(checkout).catch(() => 0) : 0;
|
|
872
|
+
if (head && head !== recorded || dirty > 0) throw new CollabError(`Branch ${worktree.branch} on this computer holds work made after its attempt was superseded by a change of owner, which cannot be handed over; start a new attempt from the latest checkpoint to continue from it`, 409);
|
|
873
|
+
}
|
|
874
|
+
if (!checkpointId) {
|
|
875
|
+
const latest = latestCheckpoint(task);
|
|
876
|
+
if (latest?.bundle === "available") checkpointId = latest.id;
|
|
877
|
+
else throw new CollabError("Nothing to hand over yet: run the task on this computer first, or wait for a checkpoint", 409);
|
|
878
|
+
}
|
|
879
|
+
const fresh = await hub.task(task.id);
|
|
880
|
+
return hub.offerHandoff(task.id, { to: input.to, checkpointId, note: sharedText(input.note, MAX.note, false, localPaths(run ?? {})) }, fresh.revision);
|
|
881
|
+
}
|
|
882
|
+
/** The bundle is imported into the recipient's clone before ownership moves, so a failed download leaves the task with its current owner. The download can take minutes, during which comments and reviews bump the task's revision: the accept goes out against the task as it is afterwards, as long as the same offer still stands. */
|
|
883
|
+
async acceptHandoff(taskId, handoffId, input = {}) {
|
|
884
|
+
const hub = this.hub, task = await hub.task(taskId), handoff = task.handoff;
|
|
885
|
+
if (!handoff || handoff.id !== handoffId) {
|
|
886
|
+
const past = task.handoffs.find((entry) => entry.id === handoffId);
|
|
887
|
+
if (past) throw new CollabError(`This hand-over was already ${past.state}`, 409);
|
|
888
|
+
throw new CollabError("Hand-over not found", 404);
|
|
889
|
+
}
|
|
890
|
+
const checkpoint = task.checkpoints.find((entry) => entry.id === handoff.checkpointId);
|
|
891
|
+
if (!checkpoint) throw new CollabError("Checkpoint not found", 404);
|
|
892
|
+
if (checkpoint.bundle !== "available") throw new CollabError("The hand-over's checkpoint bundle is not on the team server; ask the sender to hand over again", 409);
|
|
893
|
+
const repo = await this.resolveRepo(task, input.repo);
|
|
894
|
+
await this.importById(repo, checkpoint.id);
|
|
895
|
+
const fresh = await hub.task(taskId), offer = fresh.handoff;
|
|
896
|
+
if (!offer || offer.id !== handoffId) {
|
|
897
|
+
const past = fresh.handoffs.find((entry) => entry.id === handoffId);
|
|
898
|
+
if (past) throw new CollabError(`This hand-over was already ${past.state}`, 409);
|
|
899
|
+
throw new CollabError("Hand-over not found", 404);
|
|
900
|
+
}
|
|
901
|
+
return hub.respondHandoff(fresh.id, offer.id, "accept", fresh.revision);
|
|
902
|
+
}
|
|
903
|
+
/** Imports a checkpoint for review or integration and puts it on `team/<task id>/<first 8 of the checkpoint id>`, never checking it out. */
|
|
904
|
+
async fetchCheckpoint(checkpointId, input) {
|
|
905
|
+
const repo = await this.repoAt(input.repo, "Repository must be an absolute path"), { manifest, commit } = await this.importById(repo, checkpointId);
|
|
906
|
+
const result = await ensureCheckpointBranch(repo, `team/${manifest.taskId}/${manifest.id.slice(0, 8)}`, commit);
|
|
907
|
+
return { commit, branch: result.branch, taskId: manifest.taskId, created: result.created };
|
|
908
|
+
}
|
|
909
|
+
// ---- Recovery
|
|
910
|
+
/** One record against the hub (under serial): a claim is settled or forgotten, a pending finish retried, a run that still goes is followed, one that ended is published, one that is missing reported lost. Never starts a run. */
|
|
911
|
+
async settle(attemptId, result) {
|
|
912
|
+
const current = this.records.get(attemptId);
|
|
913
|
+
if (!current || this.starting.has(attemptId)) return;
|
|
914
|
+
const neverStarted = current.state === "claiming";
|
|
915
|
+
if (current.state === "claiming") {
|
|
916
|
+
let task;
|
|
917
|
+
try {
|
|
918
|
+
task = await this.hub.task(current.taskId);
|
|
919
|
+
} catch (error) {
|
|
920
|
+
if (!(error instanceof CollabError && error.status === 404)) throw error;
|
|
921
|
+
}
|
|
922
|
+
if (!task?.attempts.some((attempt) => attempt.id === current.attemptId)) {
|
|
923
|
+
await this.remove(current.attemptId);
|
|
924
|
+
result.removed++;
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
current.state = "claimed";
|
|
928
|
+
await this.save(current);
|
|
929
|
+
}
|
|
930
|
+
if (current.pendingFinish && !this.active.has(current.attemptId)) {
|
|
931
|
+
result.retried++;
|
|
932
|
+
await this.finishNow(current);
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
const run = current.runId ? this.manager.get(current.runId) : void 0;
|
|
936
|
+
if (current.state === "claimed" || current.state === "running") {
|
|
937
|
+
if (!run) {
|
|
938
|
+
if (neverStarted) await this.withoutRun(current, "failed", "The run never started on this computer; start a new attempt");
|
|
939
|
+
else await this.lost(current, current.runId ? `Run ${current.runId} is missing from this computer` : "The run's id was not recorded; confirm execution has stopped before recovering the attempt");
|
|
940
|
+
result.unknown++;
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
if (run.status === "running") {
|
|
944
|
+
if (!this.active.has(current.attemptId)) {
|
|
945
|
+
this.attach(current.attemptId, run.id);
|
|
946
|
+
result.attached++;
|
|
947
|
+
}
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
await this.finishNow(current);
|
|
951
|
+
result.finished++;
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
if ((current.state === "paused" || current.state === "stopped") && run?.status === "running" && !this.active.has(current.attemptId)) {
|
|
955
|
+
this.attach(current.attemptId, run.id);
|
|
956
|
+
result.attached++;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
/** Settles the local record of an attempt the hub shows busy for this computer when nothing runs behind it here; true when a pass ran, so the caller rereads the task. */
|
|
960
|
+
async settleStale(attemptId) {
|
|
961
|
+
await this.load();
|
|
962
|
+
const record = this.records.get(attemptId);
|
|
963
|
+
if (!record || this.active.has(attemptId) || this.starting.has(attemptId)) return false;
|
|
964
|
+
await this.serial(attemptId, () => this.settle(attemptId, emptyResult())).catch((error) => logError(`Attempt ${attemptId} could not be settled with the team server before starting another`, error));
|
|
965
|
+
return true;
|
|
966
|
+
}
|
|
967
|
+
/** After a start, a reconnect or on demand: finish what ended, follow what still runs, forget claims that never reached the hub, and retry unacknowledged finishes at once. Never starts a run. */
|
|
968
|
+
async reconcile() {
|
|
969
|
+
await this.load(true);
|
|
970
|
+
const result = emptyResult();
|
|
971
|
+
for (const record of [...this.records.values()]) {
|
|
972
|
+
if (this.closed) break;
|
|
973
|
+
if (this.starting.has(record.attemptId)) continue;
|
|
974
|
+
await this.serial(record.attemptId, () => this.settle(record.attemptId, result)).catch((error) => logError(`Attempt ${record.attemptId} could not be reconciled with the team server; it is retried by the recovery pass`, error));
|
|
975
|
+
}
|
|
976
|
+
return result;
|
|
977
|
+
}
|
|
978
|
+
/** The recovery timer's pass (every minute): claims whose answer was lost are settled and unacknowledged finishes retried with backoff, so a hub attempt left claimed or running by a lost answer is released within minutes rather than at the next restart or reconnect. Returns how many records were due. */
|
|
979
|
+
async retryPending() {
|
|
980
|
+
if (this.closed) return 0;
|
|
981
|
+
await this.load();
|
|
982
|
+
const now = this.now();
|
|
983
|
+
let due = 0;
|
|
984
|
+
for (const record of [...this.records.values()]) {
|
|
985
|
+
if (this.closed) break;
|
|
986
|
+
if (this.starting.has(record.attemptId) || this.active.has(record.attemptId)) continue;
|
|
987
|
+
const pending = record.pendingFinish;
|
|
988
|
+
if (!(record.state === "claiming" || record.state === "claimed" || pending !== void 0 && now - pending.at >= retryDelay(pending.tries))) continue;
|
|
989
|
+
due++;
|
|
990
|
+
await this.serial(record.attemptId, () => this.settle(record.attemptId, emptyResult())).catch((error) => logError(`Attempt ${record.attemptId} could not be settled with the team server; it is retried later`, error));
|
|
991
|
+
}
|
|
992
|
+
return due;
|
|
993
|
+
}
|
|
994
|
+
/** Stops timers and listeners; runs keep going and are picked up again by reconcile() after a restart. */
|
|
995
|
+
async close() {
|
|
996
|
+
this.closed = true;
|
|
997
|
+
if (this.retryTimer) clearInterval(this.retryTimer);
|
|
998
|
+
for (const live of [...this.active.values()]) this.detach(live);
|
|
999
|
+
await Promise.allSettled([...this.chains.values()]);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
export {
|
|
1003
|
+
CollabRunner,
|
|
1004
|
+
HEARTBEAT_INTERVAL_MS,
|
|
1005
|
+
MAX_FINISH_TRIES,
|
|
1006
|
+
PUBLISH_INTERVAL_MS,
|
|
1007
|
+
RETRY_INTERVAL_MS,
|
|
1008
|
+
clean,
|
|
1009
|
+
instructionNote,
|
|
1010
|
+
mapRunStatus,
|
|
1011
|
+
progressFromRun,
|
|
1012
|
+
scrubLocalPaths,
|
|
1013
|
+
taskPrompt
|
|
1014
|
+
};
|