@staix/agent-hub 0.28.0 → 0.30.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/CHANGELOG.md +22 -0
- package/README.md +5 -3
- package/docs/agent-notes/README.md +12 -0
- package/docs/agent-notes/adapters.md +2 -1
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +4 -1
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +7 -3
- package/docs/agent-notes/tests.md +3 -0
- package/docs/design/adapters.md +56 -0
- package/docs/design/agent-hub-architecture.md +12 -0
- package/docs/design/bus.md +8 -0
- package/docs/design/task-board.md +19 -1
- package/docs/dogfood.md +13 -7
- package/docs/operations.md +177 -20
- package/docs/quickstart.md +18 -3
- package/docs/sdlc.md +2 -2
- package/docs/security.md +45 -4
- package/docs/smoke.md +13 -2
- package/docs/specs/2026-10-12-office-view-design.md +86 -3
- package/docs/verification/2026-11-03-0.29.0.md +59 -0
- package/docs/verification/2026-11-03-0.30.0.md +54 -0
- package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
- package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
- package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
- package/docs/verification/2026-11-03-control-write-stall-windows.json +241 -0
- package/docs/verification/2026-11-03-control-write-stall-windows.md +20 -0
- package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
- package/docs/verification/2026-11-03-live-board-latency.json +556 -0
- package/docs/verification/2026-11-03-live-board-latency.md +64 -0
- package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
- package/docs/verification/2026-11-03-pi-title-api.md +26 -0
- package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
- package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
- package/docs/verification/dogfood/2026-11-03.md +3095 -0
- package/docs/verification/pi-150-model-free.ts +57 -0
- package/docs/verification/pi-150-native.mjs +21 -0
- package/docs/verification/pi-150-title-api.mjs +31 -0
- package/docs/verified.json +14 -14
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +715 -45
- package/src/adapters/compat.ts +10 -10
- package/src/adapters/definitions/builtin-metadata.ts +2 -2
- package/src/adapters/local-worker.ts +22 -6
- package/src/adapters/pi.ts +92 -10
- package/src/adapters/registry-types.ts +4 -0
- package/src/adapters/registry.ts +2 -0
- package/src/cli/autonomy.ts +66 -1
- package/src/cli/console-state.ts +6 -2
- package/src/cli/help.ts +3 -1
- package/src/cli/identity.ts +8 -1
- package/src/cli/main.ts +39 -0
- package/src/cli/output.ts +5 -0
- package/src/cli/recovery-package.ts +9 -0
- package/src/cli/worktree-tidy.ts +158 -0
- package/src/cli/worktrees-output.ts +43 -0
- package/src/hub/board.ts +31 -5
- package/src/hub/bus.ts +4 -4
- package/src/hub/conductor.ts +4 -3
- package/src/hub/config-apply.ts +2 -1
- package/src/hub/control-client.ts +16 -1
- package/src/hub/control-write-trace.ts +409 -0
- package/src/hub/daemon.ts +408 -66
- package/src/hub/dogfood-read.ts +62 -9
- package/src/hub/hub-tools.ts +12 -6
- package/src/hub/manager.ts +9 -1
- package/src/hub/peer-doing.ts +33 -3
- package/src/hub/peers.ts +7 -0
- package/src/hub/routing.ts +9 -2
- package/src/hub/runtime-capture-prune.ts +4 -1
- package/src/hub/sdlc-release-authorization.ts +13 -4
- package/src/hub/sdlc-release.ts +23 -10
- package/src/hub/sdlc.ts +15 -2
- package/src/hub/settings.ts +3 -0
- package/src/hub/task-worktree.ts +331 -0
- package/src/hub/tasks.ts +192 -25
- package/src/hub/ui.ts +2 -0
- package/src/hub/worktree-prs.ts +100 -0
- package/src/hub/worktree-shelf.ts +246 -0
- package/src/local/deny.ts +49 -0
- package/src/local/tools.ts +4 -2
- package/src/memory/brief.ts +5 -0
- package/src/memory/capture.ts +1 -1
- package/src/memory/client.ts +5 -2
- package/src/pi/extension.ts +43 -3
- package/src/ui/index.html +133 -10
- package/src/ui/office-model.ts +64 -8
- package/src/ui/office-space.ts +1 -0
- package/src/ui/plugin.ts +14 -3
package/src/hub/tasks.ts
CHANGED
|
@@ -68,13 +68,14 @@ import { parseClaimVerdict, claimListId, CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEY
|
|
|
68
68
|
import type { Bus } from "./bus.ts";
|
|
69
69
|
import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
70
70
|
import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
|
|
71
|
-
import { assign, isRemotePeerId, detectSignals, peerEntry, predictSplit, relayCandidates, type Assignment, type InstanceRoutingOptions, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
|
|
71
|
+
import { assign, isRemotePeerId, detectSignals, peerEntry, predictSplit, relayCandidates, writesRepository, type Assignment, type InstanceRoutingOptions, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
|
|
72
72
|
import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
|
|
73
73
|
import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
|
|
74
74
|
import { realPath } from "./project.ts";
|
|
75
75
|
import { TaskRepositories } from "./task-repository.ts";
|
|
76
76
|
import { mainCheckoutWarning } from "./main-checkout.ts";
|
|
77
77
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
78
|
+
import type { TaskWorktreePort } from "./task-worktree.ts";
|
|
78
79
|
import { coverageGap, renderReviewBrief, renderSpecTaskReport, type BriefFile, type ReviewBrief } from "./review-brief.ts";
|
|
79
80
|
import { DEFAULT_SPECS_CONFIG, type SpecsConfig } from "./specs-config.ts";
|
|
80
81
|
import type { SpecsCheckReport, SpecsCoverReport } from "./specs.ts";
|
|
@@ -82,7 +83,7 @@ import { definitionOf as builtinDefinitionOf } from "../adapters/registry.ts";
|
|
|
82
83
|
import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
83
84
|
import { plannerGrooming, nextSweep, plannedStartPending, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
|
|
84
85
|
import { CiWatchers, pollCi, type CiOutcome, type CiWatch } from "./ci-watch.ts";
|
|
85
|
-
import { planningIssueReceipt, linkedReceipt, openPrFor, readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, readSpecEdit, sdlcConfig, sdlcReceipt, sdlcStatus, specState, SPEC_STATE_TEXT, stageDefinition, lifecycleStage, type SdlcConfig, type MergeRecord, type PlanIssueMetadata, sdlcGh, type SdlcGh, type SdlcStatus, type SpecEdit, type SpecState } from "./sdlc.ts";
|
|
86
|
+
import { planningIssueReceipt, linkedReceipt, openPrFor, readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, readSdlcPrHead, readSpecEdit, sdlcConfig, sdlcReceipt, sdlcStatus, specState, SPEC_STATE_TEXT, stageDefinition, lifecycleStage, type SdlcConfig, type MergeRecord, type PlanIssueMetadata, sdlcGh, type SdlcGh, type SdlcStatus, type SpecEdit, type SpecState } from "./sdlc.ts";
|
|
86
87
|
|
|
87
88
|
function requireLocalTaskPeer(peer: unknown): void {
|
|
88
89
|
if (isRemotePeerId(peer)) throw new PreEffectToolRefusal("remote peers have no local task authority");
|
|
@@ -217,6 +218,8 @@ export interface TasksDeps {
|
|
|
217
218
|
review?: { adaptive: boolean; min_reviews: number; pool?: readonly string[]; rerotate?: boolean; same_family?: "fallback" | "never" };
|
|
218
219
|
/** Peers whose recent deliveries all failed, with the reason (issue #89); routing skips them. */
|
|
219
220
|
capabilities?: () => Record<PeerId, PeerCapabilities>;
|
|
221
|
+
/** #1115: the hub makes, commits and clears the task worktree of a hub-sandboxed peer. Absent: such a peer keeps the #1081 refusal. */
|
|
222
|
+
taskWorktree?: TaskWorktreePort;
|
|
220
223
|
repository?: string;
|
|
221
224
|
offerRead?: (peer: PeerId, envelopeId: string) => boolean;
|
|
222
225
|
offerPullOnly?: (peer: PeerId) => boolean;
|
|
@@ -429,6 +432,7 @@ export interface ExperimentView { attempts: ExperimentAttemptView[]; counts: { a
|
|
|
429
432
|
comparison?: { planHash: string; baselineAttemptId: string; variantAttemptId: string; approved: boolean; private: boolean; projection: ReturnType<typeof comparisonProjection> | "[pii]" } }
|
|
430
433
|
interface ExperimentContext { task: Task; campaign: ResearchCampaignRecord; private: boolean; key: string; fence: ExperimentWriteFence }
|
|
431
434
|
interface CompletionRefresh { revision: number; commit: string; state: "in_review" | "approved" }
|
|
435
|
+
interface RefileHeadGuard { recorded?: string }
|
|
432
436
|
interface IdentityLink { repo: string; pr: number; basis: { commit?: string; branch?: string } }
|
|
433
437
|
|
|
434
438
|
/** What one live check of a task's files says as a whole (#317): `unchanged` only when EVERY file measured unchanged. A change outranks a missing file, which outranks one the hub could not read. */
|
|
@@ -1335,6 +1339,38 @@ export class Tasks {
|
|
|
1335
1339
|
|
|
1336
1340
|
private reviewHead(task: Task): string | undefined { return task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead; }
|
|
1337
1341
|
|
|
1342
|
+
private recordedReviewHead(task: Task): string | undefined {
|
|
1343
|
+
const isFullHead = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{40}$/.test(value);
|
|
1344
|
+
const historyHead = task.history.findLast(entry => (entry.event === "approved" || entry.event === "changes_requested") && isFullHead(entry.reviewedHead))?.reviewedHead;
|
|
1345
|
+
return [task.sdlc?.reviewedHead, historyHead].find(isFullHead);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/** An owner ref, when present, outranks host-derived receipt.head values for review identity. */
|
|
1349
|
+
private ownerReviewHead(task: Task): string | undefined {
|
|
1350
|
+
return /^[a-f0-9]{40}$/.test(task.refs.commit ?? "") ? task.refs.commit : this.recordedReviewHead(task);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
private hasReviewHistory(task: Task): boolean {
|
|
1354
|
+
return task.state === "changes_requested" || !!task.sdlc?.reviewedHead || task.history.some(entry => entry.event === "approved" || entry.event === "changes_requested");
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
private reviewHeadMoveRefusal(taskId: number, recorded: string | undefined, current?: string): PreEffectToolRefusal {
|
|
1358
|
+
const known = recorded ? `recorded review head ${recorded}` : "a recorded full review head";
|
|
1359
|
+
const cause = current ? `${known} differs from current head of PR ${current}`
|
|
1360
|
+
: `current PR head could not be verified against ${known}`;
|
|
1361
|
+
const next = current ? `refs.commit set to ${current}` : "refs.commit set to the current full SHA";
|
|
1362
|
+
return new PreEffectToolRefusal(`task #${taskId}: ${cause}; owner must re-file hub_task_done with ${next} before review`);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
private async currentPrHeadForReview(task: Task, validate: () => Task): Promise<string | undefined> {
|
|
1366
|
+
const receipt = task.sdlc, gh = this.d.sdlc?.gh;
|
|
1367
|
+
if (this.isPii(task) || !receipt?.repo || !receipt.pr || !gh) return undefined;
|
|
1368
|
+
const identity = (value: Task) => JSON.stringify([value.id, value.state, value.owner, value.reviewer, value.history.length, this.isPii(value), value.refs, value.sdlc]);
|
|
1369
|
+
const before = identity(task), head = await readSdlcPrHead(receipt, gh), current = validate();
|
|
1370
|
+
if (identity(current) !== before) throw new PreEffectToolRefusal(`task #${task.id}: task or review head changed during PR readback; reload and retry`);
|
|
1371
|
+
return head;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1338
1374
|
private reviewPoolOptions(task: Task) {
|
|
1339
1375
|
const head = this.reviewHead(task);
|
|
1340
1376
|
const round = task.state === "in_review" ? task.history.slice(task.history.findLastIndex(entry => entry.event === "done") + 1) : [];
|
|
@@ -1377,16 +1413,18 @@ export class Tasks {
|
|
|
1377
1413
|
* sandboxed peer from accepting work, failing at `git worktree add` (#270) and leaving an empty branch ref behind (#621, #565). Unknown capability is
|
|
1378
1414
|
* not a refusal, and a PII task skips the check as `requireCapabilities` does.
|
|
1379
1415
|
*/
|
|
1380
|
-
private declaredNeedCheck(task: Pick<Task, "signals" | "id"> & Partial<Pick<Task, "signals">>, peer: PeerId): void {
|
|
1416
|
+
private declaredNeedCheck(task: Pick<Task, "signals" | "id"> & Partial<Pick<Task, "signals" | "class">>, peer: PeerId): void {
|
|
1381
1417
|
if (!this.d.capabilities || this.isPii(task as Task)) return;
|
|
1382
|
-
|
|
1418
|
+
// #1129: write-class work needs the hub's worktree whether or not it says so, because a hub-sandboxed owner cannot commit.
|
|
1419
|
+
const declaredWorktree = task.signals?.includes("needs_worktree") === true;
|
|
1420
|
+
const wantsNetwork = task.signals?.includes("needs_network") === true, wantsWorktree = declaredWorktree || (task.class !== undefined && writesRepository(task.class));
|
|
1383
1421
|
if (!wantsNetwork && !wantsWorktree) return;
|
|
1384
1422
|
const caps = peerEntry(this.d.capabilities(), peer);
|
|
1385
1423
|
if (wantsNetwork && caps?.network === false) {
|
|
1386
1424
|
throw new PreEffectToolRefusal(`task #${task.id} needs the network and ${peer} has none: ${caps.networkReason ?? "its shell cannot reach the network"}; decline it so the hub offers it to a peer with network, or ask the person to enable it`);
|
|
1387
1425
|
}
|
|
1388
1426
|
if (wantsWorktree && caps?.worktree === false) {
|
|
1389
|
-
throw new PreEffectToolRefusal(`task #${task.id} needs its own git worktree and ${peer} cannot create one: ${caps.worktreeReason ?? "it cannot create the task worktree"}; nothing was created, so no branch ref is left behind; decline it so the hub offers it to a peer that can, this will not change by waiting`);
|
|
1427
|
+
throw new PreEffectToolRefusal(`task #${task.id} ${declaredWorktree ? "needs its own git worktree" : "changes the repository, so the hub must make its git worktree and commit for it"} and ${peer} cannot create one: ${caps.worktreeReason ?? "it cannot create the task worktree"}; nothing was created, so no branch ref is left behind; decline it so the hub offers it to a peer that can, this will not change by waiting`);
|
|
1390
1428
|
}
|
|
1391
1429
|
}
|
|
1392
1430
|
|
|
@@ -3281,6 +3319,8 @@ export class Tasks {
|
|
|
3281
3319
|
|
|
3282
3320
|
async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; role?: unknown; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown; needs_network?: unknown; needs_worktree?: unknown; backlog?: unknown; draft?: unknown; goal?: unknown }, external?: GitHubSource, created?: () => void, fence?: () => string | undefined): Promise<Task> {
|
|
3283
3321
|
const requestCheck = this.requestCheck(by, fence);
|
|
3322
|
+
const plan = cleanPlan(input.plan, this.d.profileData ?? this.d.profile ?? "dev");
|
|
3323
|
+
this.validatePlanSources(plan);
|
|
3284
3324
|
requireLocalTaskPeer(input.owner);
|
|
3285
3325
|
external = external ? { ...external } : undefined;
|
|
3286
3326
|
if (external && (!this.personOrConductor(by) || input.backlog !== true)) throw new PreEffectToolRefusal("external intake requires person-owned backlog staging");
|
|
@@ -3317,7 +3357,6 @@ export class Tasks {
|
|
|
3317
3357
|
let baseGiven: TaskClass | undefined;
|
|
3318
3358
|
try { if (given !== undefined) baseGiven = profileClass(given, this.d.profileData ?? profile); } catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
|
|
3319
3359
|
const text = { title, detail: String(input.detail ?? "").slice(0, 8000), refs: cleanRefs(input.refs) };
|
|
3320
|
-
const plan = cleanPlan(input.plan, this.d.profileData ?? profile);
|
|
3321
3360
|
// The plan reaches other owners, so a PII pattern in it makes the task a PII task like one in the detail would.
|
|
3322
3361
|
const screenDetail = [text.detail, text.refs.repo, external?.repo, external?.url].filter(value => value !== undefined && value !== "").join("\n");
|
|
3323
3362
|
const signals = this.signalsFor({ ...text, detail: [screenDetail, planText(plan)].join("\n"), plan });
|
|
@@ -3393,10 +3432,14 @@ export class Tasks {
|
|
|
3393
3432
|
}
|
|
3394
3433
|
}
|
|
3395
3434
|
|
|
3396
|
-
async returnUntouchedToBacklog(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
|
|
3435
|
+
async returnUntouchedToBacklog(by: PeerId, input: { id: number; revision: number; reason: string; fields?: unknown }): Promise<Task> {
|
|
3397
3436
|
this.plannerWriteAuthority(by);
|
|
3398
3437
|
if (!Number.isSafeInteger(input.id) || input.id < 1 || !Number.isSafeInteger(input.revision) || input.revision < 0
|
|
3399
3438
|
|| typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("untouched return needs an id, revision and bounded reason");
|
|
3439
|
+
let parsed: PlanningFields;
|
|
3440
|
+
try { parsed = planningPatch(input.fields); } catch (error) { throw new PreEffectToolRefusal(`${(error as Error).message}; use fields.reserved with an agent peer id or null, or omit fields`); }
|
|
3441
|
+
if (Object.keys(parsed).some(key => key !== "reserved")) throw new PreEffectToolRefusal("untouched return fields may contain only reserved; remove every other field, or omit fields");
|
|
3442
|
+
const patch: Pick<PlanningFields, "reserved"> = Object.hasOwn(parsed, "reserved") ? { reserved: parsed.reserved } : {};
|
|
3400
3443
|
const validate = () => {
|
|
3401
3444
|
const task = this.need(input.id, false, true);
|
|
3402
3445
|
this.plannerWriteAuthority(by, task);
|
|
@@ -3404,6 +3447,14 @@ export class Tasks {
|
|
|
3404
3447
|
if (by !== USER && task.pinned) throw new PreEffectToolRefusal("pinned work requires the person");
|
|
3405
3448
|
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
3406
3449
|
if (release && (["shipped", "dropped"].includes(release.state) || by !== USER && release.state === "frozen")) throw new PreEffectToolRefusal("settled or frozen release scope cannot return");
|
|
3450
|
+
if (Object.hasOwn(patch, "reserved")) {
|
|
3451
|
+
if (by !== USER && this.hasPersonReservation(task)) throw new PreEffectToolRefusal("a person's reservation is retained; ask the person to change it");
|
|
3452
|
+
const reserved = patch.reserved, definitionOf = this.d.adapterCatalog?.definitionOf ?? builtinDefinitionOf;
|
|
3453
|
+
if (reserved && !definitionOf(reserved, this.d.instances?.()) && !this.d.bus.knownPeers().includes(reserved) && !this.d.routing().classes[task.class]?.peers.includes(reserved)) throw new PreEffectToolRefusal("reserved owner is not a supported or configured peer; choose a peer from ahub status");
|
|
3454
|
+
if (reserved && !this.d.bus.isRostered(reserved)) throw new PreEffectToolRefusal("reserved owner is off roster; choose a rostered peer from ahub status or ask the person to update the roster");
|
|
3455
|
+
if (reserved && this.excluded(task).includes(reserved)) throw new PreEffectToolRefusal(`${reserved} is excluded from this task (it declined it or was escalated away from it for good); a reservation for it can never be honoured. Only a person's ahub task assign lifts the exclusion`);
|
|
3456
|
+
if (reserved) this.requireCapabilities(task, reserved);
|
|
3457
|
+
}
|
|
3407
3458
|
return task;
|
|
3408
3459
|
};
|
|
3409
3460
|
const task = validate(), flow = task.history.length;
|
|
@@ -3412,14 +3463,14 @@ export class Tasks {
|
|
|
3412
3463
|
const confirmed = validate();
|
|
3413
3464
|
if (confirmed.history.length !== flow) throw new PreEffectToolRefusal("task changed during untouched return");
|
|
3414
3465
|
const capacity = this.capacityBefore(confirmed);
|
|
3415
|
-
const returned = this.d.board.update(confirmed.id, by, "backlog returned untouched", { state: "backlog", owner: null, reviewer: null }, input.reason.trim(), { ...(withheld ? { withheld: true } : {}) }, flow);
|
|
3466
|
+
const returned = this.d.board.update(confirmed.id, by, "backlog returned untouched", { state: "backlog", owner: null, reviewer: null, ...patch }, input.reason.trim(), { ...(withheld ? { withheld: true } : {}) }, flow);
|
|
3416
3467
|
this.writePlannerNotices(returned, capacity);
|
|
3417
3468
|
this.offered.delete(confirmed.id);
|
|
3418
3469
|
this.cohorts.leave(confirmed.id);
|
|
3419
3470
|
this.withdrawRoleRequest(confirmed, returned, "owner");
|
|
3420
3471
|
this.withdrawRoleRequest(confirmed, returned, "reviewer");
|
|
3421
3472
|
this.incomingBacklog.add(confirmed.id);
|
|
3422
|
-
this.planningRecorded({ by, task: confirmed.id, fields: ["return"], revision:
|
|
3473
|
+
this.planningRecorded({ by, task: confirmed.id, fields: ["return", ...Object.keys(patch)], revision: returned.planningRevision ?? 0 });
|
|
3423
3474
|
await this.releaseHeld(confirmed.owner); // its owner's slot is free (#655)
|
|
3424
3475
|
return returned;
|
|
3425
3476
|
}
|
|
@@ -4044,7 +4095,17 @@ export class Tasks {
|
|
|
4044
4095
|
}
|
|
4045
4096
|
if (stagedClaimGap) return next; // An explicit claim may reserve its owner, but no offer or acceptance bypasses review.
|
|
4046
4097
|
this.studyDispatchCheck(next, by);
|
|
4047
|
-
|
|
4098
|
+
// #1129: a self-claim of write-class work is an accept too, so a hub-sandboxed claimant gets the hub's worktree here, before the write. When the hub cannot make one the claim stays an assignment, like the missing-spec case above: `hub_task_accept` starts it and refuses with the same cause.
|
|
4099
|
+
let undoWorktree: (() => void) | undefined;
|
|
4100
|
+
try { undoWorktree = this.hubWorktreeFor(next, by); }
|
|
4101
|
+
catch (error) {
|
|
4102
|
+
if (!(error instanceof PreEffectToolRefusal)) throw error;
|
|
4103
|
+
this.releaseNotice(`${error.message}; the claim stays assigned to ${by} and is not started`);
|
|
4104
|
+
return next;
|
|
4105
|
+
}
|
|
4106
|
+
let claimed: Task;
|
|
4107
|
+
try { claimed = this.d.board.update(next.id, by, "accepted", { state: "in_progress" }); }
|
|
4108
|
+
catch (error) { undoWorktree?.(); throw error; }
|
|
4048
4109
|
this.captureOwnerAcceptance(claimed);
|
|
4049
4110
|
this.releaseNotice(`task ${this.publicTitle(claimed)} claimed by ${by}`);
|
|
4050
4111
|
return claimed;
|
|
@@ -4080,7 +4141,8 @@ export class Tasks {
|
|
|
4080
4141
|
/** A self-claim has no assignment envelope; its tool result carries the same bounded guidance. */
|
|
4081
4142
|
claimRoleGuidance(task: Task, peer: PeerId): string {
|
|
4082
4143
|
requireLocalTaskPeer(peer);
|
|
4083
|
-
|
|
4144
|
+
// #1129: a claim by a hub-sandboxed peer carries the worktree note as an accept does, since the claim result is all it reads.
|
|
4145
|
+
return task.owner === peer && task.state === "in_progress" ? [this.roleGuidance(task, peer), this.worktreeNote(task, peer)].filter(Boolean).join("\n") : "";
|
|
4084
4146
|
}
|
|
4085
4147
|
|
|
4086
4148
|
private roleGuidance(task: Task, peer: PeerId, review = false): string {
|
|
@@ -4493,6 +4555,7 @@ export class Tasks {
|
|
|
4493
4555
|
const capacity = this.capacityBefore(task);
|
|
4494
4556
|
this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
|
|
4495
4557
|
const next = this.d.board.update(task.id, by, "cancelled", { state: "cancelled", reserved: null, cancelled }, cancelled.reason, { ...(withheld ? { withheld: true as const } : {}) }, revision);
|
|
4558
|
+
this.releaseHubWorktree(task, task.owner);
|
|
4496
4559
|
this.writePlannerNotices(next, capacity);
|
|
4497
4560
|
this.releaseNotice(`task #${task.id}: cancellation cohort cleanup unavailable`, () => this.cohorts.leave(task.id));
|
|
4498
4561
|
this.offered.delete(task.id);
|
|
@@ -5648,10 +5711,18 @@ export class Tasks {
|
|
|
5648
5711
|
|
|
5649
5712
|
/** The eval record the last approval-time lifecycle read produced for a task; consumed by the verdict that follows it. */
|
|
5650
5713
|
private readonly evalRecords = new Map<number, string>();
|
|
5651
|
-
private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string, findings?: readonly ReviewFindingSet[]): Promise<PrEvidence | undefined> {
|
|
5714
|
+
private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string, findings?: readonly ReviewFindingSet[], refileHead?: RefileHeadGuard): Promise<PrEvidence | undefined> {
|
|
5652
5715
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
5653
5716
|
if (config.mode === "off") return;
|
|
5654
5717
|
const receipt = task.sdlc && { ...task.sdlc, ...(reviewedHead ? { reviewedHead } : {}) };
|
|
5718
|
+
const approvalHead = transition === "approval" ? reviewedHead ?? this.ownerReviewHead(task) ?? this.findingHead(task) : undefined;
|
|
5719
|
+
const guardedHead = !!refileHead || !!approvalHead && !!receipt?.repo && !!receipt.pr && !this.isPii(task);
|
|
5720
|
+
// Compare raw head identity on both sides; `task` may carry an uncommitted identityLink.
|
|
5721
|
+
// Completion state/history races stay with completionCurrent's existing post-readback fence.
|
|
5722
|
+
const identity = (value: Task) => JSON.stringify([value.id, value.owner, value.reviewer, this.isPii(value),
|
|
5723
|
+
value.refs.repo, value.refs.commit, value.refs.branch, value.sdlc?.repo, value.sdlc?.pr,
|
|
5724
|
+
value.sdlc?.head, value.sdlc?.localHead, value.sdlc?.reviewedHead]);
|
|
5725
|
+
const beforeHeadRead = guardedHead ? identity(this.need(task.id, false, true)) : undefined;
|
|
5655
5726
|
const deadline = performance.now() + 20_000;
|
|
5656
5727
|
const read: SdlcGh = (args, timeout, cwd, cap) => {
|
|
5657
5728
|
const current = this.need(task.id, false, true);
|
|
@@ -5660,6 +5731,16 @@ export class Tasks {
|
|
|
5660
5731
|
return remaining > 0 ? (this.d.sdlc?.gh ?? sdlcGh)(args, Math.min(timeout ?? 10_000, remaining), cwd, cap) : Promise.resolve({ code: 1, stdout: "" });
|
|
5661
5732
|
};
|
|
5662
5733
|
const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, read, true) : undefined;
|
|
5734
|
+
const currentHead = receipt && !this.isPii(task) && (refileHead || approvalHead) && !evidence?.head
|
|
5735
|
+
? await readSdlcPrHead(receipt, read) : evidence?.head;
|
|
5736
|
+
if (beforeHeadRead !== undefined) {
|
|
5737
|
+
const current = this.need(task.id, false, true);
|
|
5738
|
+
if (identity(current) !== beforeHeadRead) throw new PreEffectToolRefusal(`task #${task.id}: task or review head changed during PR readback; reload and retry`);
|
|
5739
|
+
}
|
|
5740
|
+
if (refileHead) {
|
|
5741
|
+
if (!refileHead.recorded || !currentHead) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded);
|
|
5742
|
+
if (currentHead !== refileHead.recorded) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded, currentHead);
|
|
5743
|
+
} else if (approvalHead && currentHead && currentHead !== approvalHead) throw this.reviewHeadMoveRefusal(task.id, approvalHead, currentHead);
|
|
5663
5744
|
const integrity = publicTestIntegrity(receipt && evidence && !this.isPii(task) ? await readTestIntegrity(receipt, evidence, read) : this.unknownTestIntegrity(this.isPii(task)), this.publicMetadataText);
|
|
5664
5745
|
const evalResult = receipt && !this.isPii(task) ? await readEvalFor(config, evidence, receipt.repo, read) : undefined;
|
|
5665
5746
|
const current = this.need(task.id, false, true);
|
|
@@ -5708,6 +5789,8 @@ export class Tasks {
|
|
|
5708
5789
|
const metadata = acceptRefs(refs);
|
|
5709
5790
|
const task = this.need(id, false, true);
|
|
5710
5791
|
this.mine(task, by, "owner", true);
|
|
5792
|
+
const proposedPlan = plan == null ? undefined : cleanPlan(plan, (task.profile ?? "dev") === (this.d.profile ?? "dev") ? this.d.profileData ?? task.profile ?? "dev" : task.profile ?? "dev");
|
|
5793
|
+
this.validatePlanSources(proposedPlan && Object.keys(proposedPlan).length ? proposedPlan : task.plan);
|
|
5711
5794
|
this.acceptanceCapacity(task, 0, "check"); // #1080: an urgent task may still take unaccepted offers; `accept` does that, after its gates
|
|
5712
5795
|
if (metadata && !this.isPii(task) && (!this.nameable(metadata.repo!) || looksSecret(metadata.repo!) || this.aboutPrivatePath("", metadata.repo!, task))) throw new PreEffectToolRefusal("this repository metadata is private or sensitive and is not kept");
|
|
5713
5796
|
const draft = metadata ? { ...task, refs: { ...task.refs, ...metadata } } : task;
|
|
@@ -5822,6 +5905,12 @@ export class Tasks {
|
|
|
5822
5905
|
throw new PreEffectToolRefusal(`planner WIP capacity for ${owner}: limit ${limit}; open tasks ${ids(open)} (${parts.join("; ")})${pending ? `; pending claims ${pending}` : ""}; ${next}`);
|
|
5823
5906
|
}
|
|
5824
5907
|
|
|
5908
|
+
private validatePlanSources(plan: TaskPlan = {}): void {
|
|
5909
|
+
for (const id of plan.sources ?? []) if (!this.d.intake?.sourceKnown(id)) {
|
|
5910
|
+
throw new PreEffectToolRefusal(`unknown source ${id}; list registered ids with ahub source list`);
|
|
5911
|
+
}
|
|
5912
|
+
}
|
|
5913
|
+
|
|
5825
5914
|
/**
|
|
5826
5915
|
* With a plan, the owners of overlapping tasks get it as a ride-along line, and an overlap only the plan reveals is
|
|
5827
5916
|
* announced like one found at assignment (issue #31).
|
|
@@ -5833,6 +5922,11 @@ export class Tasks {
|
|
|
5833
5922
|
this.mine(task, by, "owner", true);
|
|
5834
5923
|
this.ready(task, true);
|
|
5835
5924
|
if (task.state !== "proposed" && task.state !== "changes_requested" && task.state !== "in_progress") throw new PreEffectToolRefusal(`task #${task.id} is ${task.state}: cannot move to in_progress`);
|
|
5925
|
+
// Models send null or {} for an optional field they leave empty: neither replaces a plan.
|
|
5926
|
+
const given = plan == null ? undefined : cleanPlan(plan, (task.profile ?? "dev") === (this.d.profile ?? "dev") ? this.d.profileData ?? task.profile ?? "dev" : task.profile ?? "dev");
|
|
5927
|
+
// File-list intent still replaces the old lists; omitted budget keys retain the planner-reviewed scope.
|
|
5928
|
+
const cleaned = given && Object.keys(given).length ? { ...cleanCostPlan(task.plan ?? {}), ...given } : undefined;
|
|
5929
|
+
this.validatePlanSources(cleaned ?? task.plan);
|
|
5836
5930
|
this.studyDispatchCheck(task, task.owner ?? undefined);
|
|
5837
5931
|
this.declaredNeedCheck(task, task.owner ?? by); // the owner's capability, also when the console user accepts on its behalf (`mine` admits USER)
|
|
5838
5932
|
this.acceptanceCapacity(task, 0, "check");
|
|
@@ -5843,10 +5937,6 @@ export class Tasks {
|
|
|
5843
5937
|
this.reviewedAdmissions.delete(task.id);
|
|
5844
5938
|
if (!readback || readback.flow !== task.history.length || readback.owner !== task.owner) throw new PreEffectToolRefusal("current plan-review readback required before acceptance");
|
|
5845
5939
|
}
|
|
5846
|
-
// Models send null or {} for an optional field they leave empty: neither replaces a plan.
|
|
5847
|
-
const given = plan == null ? undefined : cleanPlan(plan, (task.profile ?? "dev") === (this.d.profile ?? "dev") ? this.d.profileData ?? task.profile ?? "dev" : task.profile ?? "dev");
|
|
5848
|
-
// File-list intent still replaces the old lists; omitted budget keys retain the planner-reviewed scope.
|
|
5849
|
-
const cleaned = given && Object.keys(given).length ? { ...cleanCostPlan(task.plan ?? {}), ...given } : undefined;
|
|
5850
5940
|
if (this.d.sdlc?.config().mode === "enforce" && task.sdlc?.planReview && !task.sdlc.planReview.scope
|
|
5851
5941
|
&& given && Object.keys(cleanCostPlan(given)).length) throw new PreEffectToolRefusal("legacy plan cost scope is unspecified; record a scoped planner review before acceptance");
|
|
5852
5942
|
if (this.d.sdlc?.config().mode === "enforce" && task.sdlc?.planReview?.scope
|
|
@@ -5863,8 +5953,13 @@ export class Tasks {
|
|
|
5863
5953
|
const repositoryChanged = this.repositories.scope(task.refs).key !== this.repositories.scope(draft.refs).key;
|
|
5864
5954
|
const before = new Set(this.overlapHits(task).map((h) => h.task.id));
|
|
5865
5955
|
this.studyDispatchCheck(task, task.owner ?? undefined);
|
|
5866
|
-
|
|
5867
|
-
const
|
|
5956
|
+
// #1115: a hub-sandboxed peer cannot run `git worktree add`, so for work that declares a worktree the hub makes it here, before any write. Anything that throws below clears it again.
|
|
5957
|
+
const undoWorktree = this.hubWorktreeFor(task, task.owner ?? by);
|
|
5958
|
+
let next: Task;
|
|
5959
|
+
try {
|
|
5960
|
+
this.acceptanceCapacity(task, 0, "displace"); // #1080: every gate has passed; synchronous from here to the accept write
|
|
5961
|
+
next = this.linkOnWrite(this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}), ...(metadata ? { refs: metadata } : {}) })); // #723: refs.repo may have just arrived
|
|
5962
|
+
} catch (error) { undoWorktree?.(); throw error; }
|
|
5868
5963
|
this.captureOwnerAcceptance(next);
|
|
5869
5964
|
this.d.notify(`task ${this.publicTitle(next)} accepted by ${by}`);
|
|
5870
5965
|
if (repositoryChanged) this.cohorts.leave(next.id);
|
|
@@ -5925,6 +6020,51 @@ export class Tasks {
|
|
|
5925
6020
|
/** #572 AC3: what the owner's `hub_task_done` result adds while the project root checkout is on a task branch. */
|
|
5926
6021
|
checkoutNote = (): string | undefined => { const warning = mainCheckoutWarning(this.d.cwd, this.publicMetadataText); return warning ? `Note: ${warning}.` : undefined; };
|
|
5927
6022
|
|
|
6023
|
+
/**
|
|
6024
|
+
* #1115, #1129: the hub makes the worktree of a hub-sandboxed owner for write-class work (`implement`, `bulk_edit`) and for any work that declares `needs_worktree`. Returns a function that clears it again, or
|
|
6025
|
+
* undefined when there is nothing to clear (another peer, a read-only class that did not declare it, a PII task, or a worktree an earlier accept already made). A failure refuses the
|
|
6026
|
+
* accept before any board write and names the cause and the next step; nothing is left behind.
|
|
6027
|
+
*/
|
|
6028
|
+
private hubWorktreeFor(task: Task, peer: PeerId): (() => void) | undefined {
|
|
6029
|
+
const port = this.d.taskWorktree;
|
|
6030
|
+
if (!port || peer === USER || peer === HUB || !port.applies(peer) || this.isPii(task) || !(task.signals.includes("needs_worktree") || writesRepository(task.class))) return undefined;
|
|
6031
|
+
const made = port.create({ peer, task: task.id });
|
|
6032
|
+
if (!made.ok) throw new PreEffectToolRefusal(`task #${task.id} needs its own git worktree and the hub could not make one for ${peer}: ${made.reason}; nothing was created, so no branch ref is left behind. Decline the task so the hub offers it to a peer that makes its own worktree`);
|
|
6033
|
+
return made.created ? () => { try { port.remove({ peer, task: task.id }); } catch { /* the accept error that got us here is the one reported */ } } : undefined;
|
|
6034
|
+
}
|
|
6035
|
+
|
|
6036
|
+
/** The hub-made worktree of the task's owner, for the accept answer: where to edit, and what the hub does at `hub_task_done`. */
|
|
6037
|
+
worktreeNote(task: Task, peer: PeerId): string | undefined {
|
|
6038
|
+
const found = this.d.taskWorktree?.describe(peer, task.id);
|
|
6039
|
+
return found ? `Worktree: the hub made ${found.path} on branch ${found.branch} for this task. Edit files there only; do not run git. At hub_task_done the hub stages, commits and pushes it for you.` : undefined;
|
|
6040
|
+
}
|
|
6041
|
+
|
|
6042
|
+
/**
|
|
6043
|
+
* #1115: at `hub_task_done` the hub commits and pushes a hub-sandboxed peer's worktree and answers the refs the done carries. Undefined when the peer made no
|
|
6044
|
+
* hub worktree for this task (it commits itself). A refused commit or push is a pre-effect refusal that names the cause and the next step: the board is untouched.
|
|
6045
|
+
*/
|
|
6046
|
+
async worktreeHandoff(by: PeerId, id: unknown, refs: unknown): Promise<TaskRefs | undefined> {
|
|
6047
|
+
const port = this.d.taskWorktree;
|
|
6048
|
+
if (!port || by === USER || by === HUB || !port.applies(by)) return undefined;
|
|
6049
|
+
const task = this.need(id, false, true);
|
|
6050
|
+
this.mine(task, by, "owner", true);
|
|
6051
|
+
if (this.isPii(task) || !port.describe(by, task.id)) return undefined;
|
|
6052
|
+
const result = await port.commitAndPush({ peer: by, task: task.id, title: task.title });
|
|
6053
|
+
if (!result.ok) throw new PreEffectToolRefusal(`hub_task_done for task #${task.id}: ${result.reason}`);
|
|
6054
|
+
if (result.note) this.doneNotes.set(task.id, result.note);
|
|
6055
|
+
return { ...cleanRefs(refs), commit: result.commit, branch: result.branch };
|
|
6056
|
+
}
|
|
6057
|
+
|
|
6058
|
+
/** Decline or cancel: clear the hub-made worktree and its empty branch. Uncommitted work is kept and reported, never force-removed. */
|
|
6059
|
+
private releaseHubWorktree(task: Task, peer: PeerId | null | undefined): void {
|
|
6060
|
+
const port = this.d.taskWorktree;
|
|
6061
|
+
if (!port || !peer || peer === USER || peer === HUB || !port.applies(peer)) return;
|
|
6062
|
+
this.releaseNotice(`task #${task.id}: the hub-made worktree could not be cleared`, () => {
|
|
6063
|
+
const removal = port.remove({ peer, task: task.id });
|
|
6064
|
+
if (removal.kept) this.d.notify(`task #${task.id}: ${removal.kept}`);
|
|
6065
|
+
});
|
|
6066
|
+
}
|
|
6067
|
+
|
|
5928
6068
|
/**
|
|
5929
6069
|
* `capacity` (#531): the owner has no room right now, so the decline excludes it only from this move's own reroute
|
|
5930
6070
|
* (below), never from a later reroute or planner pull. Without it, a decline is a lasting refusal (`excluded()`), and so
|
|
@@ -5939,6 +6079,7 @@ export class Tasks {
|
|
|
5939
6079
|
const back = this.d.board.update(task.id, by, "declined", { ...(["in_progress", "changes_requested"].includes(task.state) ? { state: "proposed" as const } : {}), ...(planned ? { reserved: null } : {}) }, reason, task.owner ? { from: task.owner, ...(capacity === true ? { capacity: true as const } : {}) } : {});
|
|
5940
6080
|
if (planned) this.offered.delete(task.id);
|
|
5941
6081
|
this.d.notify(`task ${this.publicTitle(back)} declined${capacity === true ? " (capacity only)" : ""} by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
|
|
6082
|
+
this.releaseHubWorktree(task, task.owner ?? by);
|
|
5942
6083
|
// A capacity decline is not in excluded(), so this move's own reroute leaves the decliner out explicitly.
|
|
5943
6084
|
try {
|
|
5944
6085
|
return await this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true, ...(capacity === true && task.owner ? { exclude: [task.owner] } : {}), ...(planned ? { planner: true } : {}) });
|
|
@@ -6122,11 +6263,20 @@ export class Tasks {
|
|
|
6122
6263
|
let refresh: CompletionRefresh | undefined;
|
|
6123
6264
|
if (initial.state === "in_review" || initial.state === "approved") {
|
|
6124
6265
|
const commit = cleanRefs(refs).commit;
|
|
6125
|
-
|
|
6126
|
-
|
|
6266
|
+
const recorded = this.ownerReviewHead(initial) ?? this.findingHead(initial);
|
|
6267
|
+
const currentReviewHead = this.ownerReviewHead(initial) ?? this.reviewHead(initial);
|
|
6268
|
+
if (!commit || !/^[a-f0-9]{40}$/.test(commit) || commit === currentReviewHead) {
|
|
6269
|
+
throw new PreEffectToolRefusal(`task #${initial.id} is already ${initial.state}${recorded ? ` with recorded review head ${recorded}` : ""}; re-file requires a different full head SHA. Owner must re-file hub_task_done with refs.commit set to the new full SHA`);
|
|
6270
|
+
}
|
|
6127
6271
|
refresh = { revision: initial.history.length, commit, state: initial.state };
|
|
6128
6272
|
}
|
|
6129
6273
|
const submittedCommit = cleanRefs(refs).commit;
|
|
6274
|
+
if (submittedCommit && this.hasReviewHistory(initial) && !/^[a-f0-9]{40}$/.test(submittedCommit)) {
|
|
6275
|
+
const recorded = this.ownerReviewHead(initial);
|
|
6276
|
+
throw new PreEffectToolRefusal(`task #${initial.id}: re-file after review needs refs.commit to be a full lowercase SHA${recorded ? ` (recorded review head ${recorded})` : ""}; owner must re-file hub_task_done with the new full SHA in refs.commit`);
|
|
6277
|
+
}
|
|
6278
|
+
const refileHead: RefileHeadGuard | undefined = !submittedCommit && initial.sdlc?.repo && initial.sdlc.pr && this.hasReviewHistory(initial) && !this.isPii(initial)
|
|
6279
|
+
? { recorded: this.ownerReviewHead(initial) } : undefined;
|
|
6130
6280
|
const draft = refresh ?? (submittedCommit && /^[a-f0-9]{40}$/.test(submittedCommit) ? { commit: submittedCommit } : undefined);
|
|
6131
6281
|
const valid = () => { requestCheck(); return this.completionDraft(this.completionCurrent(id, by, true, refresh), draft); };
|
|
6132
6282
|
if (initial.state === "approved") { await this.unmergedForReopen(initial, valid); valid(); }
|
|
@@ -6150,9 +6300,15 @@ export class Tasks {
|
|
|
6150
6300
|
const current = () => this.withLink(valid(), link, refs);
|
|
6151
6301
|
task = current();
|
|
6152
6302
|
let reviewEvidence: PrEvidence | undefined;
|
|
6153
|
-
if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review"); task = current(); }
|
|
6154
|
-
const reviewAdmission = task.reviewer || this.d.prepareReviewBrief ? await this.reviewRequestAdmission(task, refs, true, by, reviewEvidence, undefined, staged => this.withLink(this.completionDraft(staged, draft), link, refs), !task.reviewer) : undefined;
|
|
6303
|
+
if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review", undefined, undefined, refileHead); task = current(); }
|
|
6304
|
+
const reviewAdmission = task.reviewer || this.d.prepareReviewBrief ? await this.reviewRequestAdmission(task, refs, true, by, reviewEvidence, undefined, staged => this.withLink(this.completionDraft(staged, draft), link, refs), !task.reviewer, refileHead) : undefined;
|
|
6155
6305
|
task = current();
|
|
6306
|
+
if (refileHead && (this.d.sdlc?.config().mode ?? "off") === "off") {
|
|
6307
|
+
const currentHead = await this.currentPrHeadForReview(task, current);
|
|
6308
|
+
task = current();
|
|
6309
|
+
if (!refileHead.recorded || !currentHead) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded);
|
|
6310
|
+
if (currentHead !== refileHead.recorded) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded, currentHead);
|
|
6311
|
+
}
|
|
6156
6312
|
const currentSpecTask = () => { const fresh = current(); return { ...fresh, refs: { ...fresh.refs, ...cleanRefs(refs) } }; };
|
|
6157
6313
|
const specInspection = await this.inspectTaskSpecs(currentSpecTask(), by, specAck, currentSpecTask);
|
|
6158
6314
|
task = current(); // the host head and task authority are revalidated after spec inspection
|
|
@@ -6507,7 +6663,7 @@ export class Tasks {
|
|
|
6507
6663
|
|
|
6508
6664
|
/** The one place a review request is written: the first reviewer and a replacement get the same text, refs and privacy. */
|
|
6509
6665
|
/** Review admission is separate from brief construction (#228). All refusals precede the requesting done's writes. */
|
|
6510
|
-
private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence, sameRequest?: () => boolean, stage: (task: Task) => Task = task => task, prepareOnly = false): Promise<ReviewReadiness | undefined> {
|
|
6666
|
+
private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence, sameRequest?: () => boolean, stage: (task: Task) => Task = task => task, prepareOnly = false, refileHead?: RefileHeadGuard): Promise<ReviewReadiness | undefined> {
|
|
6511
6667
|
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
6512
6668
|
if (mode === "off" && !this.d.prepareReviewBrief) return undefined;
|
|
6513
6669
|
let quietDrift = false;
|
|
@@ -6544,8 +6700,13 @@ export class Tasks {
|
|
|
6544
6700
|
const evidence: ReviewReadiness = this.isPii(task)
|
|
6545
6701
|
? { ready: false, blockers: ["private review readiness unavailable"] }
|
|
6546
6702
|
: task.sdlc ? reviewReadinessFromEvidence(previous, expected, this.d.sdlc?.config().requiredChecks ?? []) ?? await readReviewReadiness(task.sdlc, expected, read, this.d.sdlc?.config().requiredChecks ?? []) : { ready: false, blockers: ["review PR identity missing"] };
|
|
6703
|
+
const currentHead = refileHead && !evidence.head && task.sdlc ? await readSdlcPrHead(task.sdlc, read) : evidence.head;
|
|
6547
6704
|
const current = guard();
|
|
6548
6705
|
const admitted: ReviewReadiness = quietDrift ? { ready: false, blockers: ["review readiness unknown: task changed during readback"] } : evidence;
|
|
6706
|
+
if (refileHead) {
|
|
6707
|
+
if (!refileHead.recorded || !currentHead) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded);
|
|
6708
|
+
if (currentHead !== refileHead.recorded) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded, currentHead);
|
|
6709
|
+
}
|
|
6549
6710
|
if (!admitted.ready) {
|
|
6550
6711
|
const message = `task ${this.publicTitle(current)}: premature review: ${admitted.blockers.join("; ")}`;
|
|
6551
6712
|
if (mode === "enforce") throw new (preEffect ? PreEffectToolRefusal : Error)(message);
|
|
@@ -6879,7 +7040,7 @@ export class Tasks {
|
|
|
6879
7040
|
// notice. The rule is structural (a finding with disposition open), never a reading of the unmet text.
|
|
6880
7041
|
const codeScope = by !== USER && this.d.reviewScope?.(by) === "code";
|
|
6881
7042
|
const evidenceOnly = codeScope && verdict === "changes_requested" && !parsed.some(finding => finding.disposition === "open");
|
|
6882
|
-
const resolvedHead = this.findingHead(initial);
|
|
7043
|
+
const resolvedHead = this.ownerReviewHead(initial) ?? this.findingHead(initial);
|
|
6883
7044
|
let findingHead = resolvedHead;
|
|
6884
7045
|
if (head !== undefined) {
|
|
6885
7046
|
try { findingHead = sdlcReceipt({ reviewedHead: head }, initial.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined)).reviewedHead; }
|
|
@@ -6926,7 +7087,7 @@ export class Tasks {
|
|
|
6926
7087
|
if (gap.length) note = `${note ?? ""}\nCoverage gap (${gap.length}): ${list(gap)}`.trim();
|
|
6927
7088
|
}
|
|
6928
7089
|
}
|
|
6929
|
-
if (head !== undefined && resolvedHead && findingHead !== resolvedHead) throw new PreEffectToolRefusal(
|
|
7090
|
+
if (head !== undefined && resolvedHead && findingHead !== resolvedHead) throw new PreEffectToolRefusal(`task #${initial.id}: supplied review head ${findingHead} differs from recorded task head ${resolvedHead}; owner must re-file hub_task_done with refs.commit set to the supplied full SHA before review`);
|
|
6930
7091
|
// The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
|
|
6931
7092
|
const withheld = await this.withholds("review note", parsed.length ? JSON.stringify({ note, findings: parsed }) : note, valid())
|
|
6932
7093
|
|| parsed.length > 0 && !this.findingTextAllowed(initial, parsed);
|
|
@@ -6963,6 +7124,12 @@ export class Tasks {
|
|
|
6963
7124
|
await this.lifecycleGate(task, "approval", reviewedHead, proposedSets(task)); task = valid();
|
|
6964
7125
|
const record = this.evalRecords.get(task.id); this.evalRecords.delete(task.id);
|
|
6965
7126
|
if (record) note = `${note ?? ""}\n${record}`.trim();
|
|
7127
|
+
} else if (verdict === "approved" && this.d.sdlc?.config().mode === "off" && this.d.sdlc?.gh && task.sdlc?.repo && task.sdlc.pr
|
|
7128
|
+
&& this.ownerReviewHead(task) && !this.isPii(task)) {
|
|
7129
|
+
const expected = reviewedHead ?? this.ownerReviewHead(task) ?? this.findingHead(task);
|
|
7130
|
+
const currentHead = await this.currentPrHeadForReview(task, valid);
|
|
7131
|
+
task = valid();
|
|
7132
|
+
if (expected && currentHead && currentHead !== expected) throw this.reviewHeadMoveRefusal(task.id, expected, currentHead);
|
|
6966
7133
|
}
|
|
6967
7134
|
let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
|
|
6968
7135
|
if (verdict === "changes_requested" && !evidenceOnly && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
|
package/src/hub/ui.ts
CHANGED
|
@@ -149,7 +149,9 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
149
149
|
return reject(403, "live-test dashboard cannot write person-attributed content; use peer tools");
|
|
150
150
|
}
|
|
151
151
|
if (input.action === "sdlc_queue" || input.action === "sdlc_release") server.timeout(req, 35);
|
|
152
|
+
else if (input.action === "worktrees_refresh_prs") server.timeout(req, 45); // #1057: bounded gh reads (25 s) and two git passes
|
|
152
153
|
else if (input.action === "backlog_split") server.timeout(req, Math.ceil((BACKLOG_SPLIT_SCREEN_TIMEOUT_MS + HUB_DB_BUSY_TIMEOUT_MS + 5_000) / 1000) + 5);
|
|
154
|
+
else if (input.action === "intake") server.timeout(req, 95); // the CLI's 90 s intake cap plus the HTTP response margin
|
|
153
155
|
return json(await options.action(input, sessionOf(cookie)));
|
|
154
156
|
} catch {
|
|
155
157
|
// Exceptions may quote private task text or backend details. Never echo them to the page.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { readSdlcPr, type SdlcGh } from "./sdlc.ts";
|
|
2
|
+
import type { PrObserver, WorktreeFact } from "./worktree-shelf.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* #1057 D1: the explicit "refresh PR states" action. It is the ONLY code that lets the worktree shelf learn a PR state from GitHub, through `readSdlcPr`
|
|
6
|
+
* (the same readback the guarded merge uses), and it runs only when a person asks. A panel refresh, `ahub worktrees` and the inbox alert never reach it, so
|
|
7
|
+
* they make no GitHub call (AC5): `worktree-shelf.ts` imports nothing from here and holds no GitHub access.
|
|
8
|
+
*
|
|
9
|
+
* What it reads is kept in memory, keyed by repository and PR number, and offered back to the shelf as a `PrObserver`.
|
|
10
|
+
* ponytail: the observations live in the daemon's memory, so a restart forgets them and those rows read `unknown` again until the next refresh; the upgrade is a
|
|
11
|
+
* `worktree_pr` table beside `worktree_override`, which is not worth a schema change before live use shows people refresh often.
|
|
12
|
+
*/
|
|
13
|
+
export const REFRESH_MAX_PRS = 8, REFRESH_CALL_MS = 8_000, REFRESH_TOTAL_MS = 25_000, OBSERVATIONS_MAX = 512;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* When GitHub says a PR that is no longer open was merged or closed, in ms. A second, narrow read (`pr view <n> --json mergedAt,closedAt`) that only a refresh makes and
|
|
17
|
+
* only for a PR `readSdlcPr` found merged or closed, so the shared readback the guarded merge and the queue use is unchanged. A missing, malformed or non-finite time is undefined,
|
|
18
|
+
* never 0: a merged PR with no change time is `unknown` to the partition, not settled.
|
|
19
|
+
*/
|
|
20
|
+
export async function readPrChangeTime(repo: string, pr: number, state: "merged" | "closed", gh: SdlcGh): Promise<number | undefined> {
|
|
21
|
+
// An owner and a name, each starting with a letter or digit: `..`, `.x` and `-x` are never a repository, so a hostile slug cannot reach gh as a path or a flag.
|
|
22
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9_.][A-Za-z0-9_.-]*$/.test(repo) || repo.split("/").some(part => part === "." || part === "..") || !Number.isSafeInteger(pr) || pr < 1) return undefined;
|
|
23
|
+
try {
|
|
24
|
+
const view = await gh(["pr", "view", String(pr), "--repo", repo, "--json", "mergedAt,closedAt"], 8_000, undefined, 4096);
|
|
25
|
+
if (view.code !== 0) return undefined;
|
|
26
|
+
const value = JSON.parse(view.stdout) as { mergedAt?: unknown; closedAt?: unknown } | null;
|
|
27
|
+
const text = state === "merged" ? value?.mergedAt ?? value?.closedAt : value?.closedAt;
|
|
28
|
+
const at = typeof text === "string" && text.length <= 40 ? Date.parse(text) : NaN;
|
|
29
|
+
return Number.isFinite(at) && at > 0 ? at : undefined;
|
|
30
|
+
} catch { return undefined; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PrObservation { state: "open" | "merged" | "closed"; changedAt?: number; at: number }
|
|
34
|
+
export interface RefreshResult {
|
|
35
|
+
/** PRs read and stored. */
|
|
36
|
+
read: number;
|
|
37
|
+
/** PRs GitHub could not answer for (unreadable, no access, a gh failure): their state stays what it was. */
|
|
38
|
+
failed: number;
|
|
39
|
+
/** PRs not asked about because the cap or the time budget was reached. */
|
|
40
|
+
skipped: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A bounded, insertion-ordered map of what the last refreshes read. */
|
|
44
|
+
export class PrObservations {
|
|
45
|
+
private readonly seen = new Map<string, PrObservation>();
|
|
46
|
+
record(repo: string, pr: number, observation: PrObservation): void {
|
|
47
|
+
const key = `${repo}#${pr}`;
|
|
48
|
+
this.seen.delete(key); this.seen.set(key, observation);
|
|
49
|
+
while (this.seen.size > OBSERVATIONS_MAX) this.seen.delete(this.seen.keys().next().value!);
|
|
50
|
+
}
|
|
51
|
+
/** The shelf's view of it: state and change time only. */
|
|
52
|
+
observer: PrObserver = (repo, pr) => {
|
|
53
|
+
const found = this.seen.get(`${repo}#${pr}`);
|
|
54
|
+
return found ? { state: found.state, ...(found.changedAt !== undefined ? { changedAt: found.changedAt } : {}) } : undefined;
|
|
55
|
+
};
|
|
56
|
+
/** When the newest observation was read, or undefined when none was. */
|
|
57
|
+
get lastAt(): number | undefined { let at: number | undefined; for (const value of this.seen.values()) at = Math.max(at ?? 0, value.at); return at; }
|
|
58
|
+
get size(): number { return this.seen.size; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The PRs a refresh would ask about: bound tasks with a repository and a PR that the board does not already show as merged, oldest activity first. */
|
|
62
|
+
export function refreshTargets(facts: readonly WorktreeFact[], limit = REFRESH_MAX_PRS): { repo: string; pr: number }[] {
|
|
63
|
+
const seen = new Set<string>(), out: { repo: string; pr: number }[] = [];
|
|
64
|
+
const candidates = facts.filter(f => !f.main && !f.instance && f.task?.repo !== undefined && f.task.pr !== undefined && f.pr.state !== "merged")
|
|
65
|
+
.sort((a, b) => a.lastActiveAt - b.lastActiveAt);
|
|
66
|
+
for (const fact of candidates) {
|
|
67
|
+
const key = `${fact.task!.repo}#${fact.task!.pr}`;
|
|
68
|
+
if (seen.has(key)) continue;
|
|
69
|
+
seen.add(key); out.push({ repo: fact.task!.repo!, pr: fact.task!.pr! });
|
|
70
|
+
}
|
|
71
|
+
return out.slice(0, limit);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read each target once, one at a time, under one total budget. `gh` is the host's runner (`sdlcGh` bound to the project), never an argument a request supplies.
|
|
76
|
+
* `readSdlcPr` validates the repository and PR number itself and builds a fixed read-only argv. Every failure is counted and the old state stands.
|
|
77
|
+
*/
|
|
78
|
+
export async function refreshPrStates(
|
|
79
|
+
targets: readonly { repo: string; pr: number }[],
|
|
80
|
+
gh: SdlcGh,
|
|
81
|
+
store: PrObservations,
|
|
82
|
+
options: { now?: () => number; totalMs?: number; callMs?: number; max?: number } = {},
|
|
83
|
+
): Promise<RefreshResult> {
|
|
84
|
+
const now = options.now ?? Date.now, started = now(), total = options.totalMs ?? REFRESH_TOTAL_MS, perCall = options.callMs ?? REFRESH_CALL_MS;
|
|
85
|
+
const left = () => Math.max(0, started + total - now());
|
|
86
|
+
const bounded: SdlcGh = async (args, timeoutMs, cwd, maxOutputBytes) => left() <= 0 ? { code: 1, stdout: "" } : gh(args, Math.max(1, Math.min(timeoutMs ?? perCall, perCall, left())), cwd, maxOutputBytes);
|
|
87
|
+
const asked = targets.slice(0, options.max ?? REFRESH_MAX_PRS);
|
|
88
|
+
let read = 0, failed = 0, done = 0;
|
|
89
|
+
for (const target of asked) {
|
|
90
|
+
if (left() <= 0) break;
|
|
91
|
+
done++;
|
|
92
|
+
const evidence = await readSdlcPr({ stage: "pr", repo: target.repo, pr: target.pr }, bounded).catch(() => undefined);
|
|
93
|
+
if (!evidence) { failed++; continue; }
|
|
94
|
+
const state = evidence.state === "MERGED" ? "merged" : evidence.state === "CLOSED" ? "closed" : "open";
|
|
95
|
+
const changedAt = state === "open" ? undefined : await readPrChangeTime(target.repo, target.pr, state, bounded);
|
|
96
|
+
store.record(target.repo, target.pr, { state, ...(changedAt !== undefined ? { changedAt } : {}), at: now() });
|
|
97
|
+
read++;
|
|
98
|
+
}
|
|
99
|
+
return { read, failed, skipped: targets.length - done };
|
|
100
|
+
}
|