@afokapu/atdd-flow 0.3.70 → 0.3.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -2
- package/extensions/pi/index.ts +124 -4
- package/package.json +1 -1
- package/src/address-book.ts +4 -4
- package/src/adoption-authorizations.ts +7 -1
- package/src/cli.ts +18 -6
- package/src/collaboration-policy.ts +6 -0
- package/src/core.ts +37 -0
- package/src/exact-adoption-recovery.ts +5 -1
- package/src/handover.ts +14 -9
- package/src/multiplexer.ts +7 -1
- package/src/next-action-notices.ts +16 -0
- package/src/operator-launch.ts +1 -1
- package/src/pi-runtime-launch.ts +14 -3
- package/src/pi-wake-protocol.ts +62 -0
- package/src/pi-wake.ts +291 -0
- package/src/reconcile.ts +8 -3
- package/src/runtime-state.ts +7 -0
- package/src/stale-main-recovery.ts +164 -0
- package/src/tasks.ts +102 -2
- package/src/threads.ts +6 -2
- package/src/vcs-migration.ts +126 -32
package/README.md
CHANGED
|
@@ -130,7 +130,7 @@ atdd-flow task review example-app runtime-rollout --by driver.runtime@example-ap
|
|
|
130
130
|
atdd-flow task done example-app runtime-rollout --by coordinator@example-app
|
|
131
131
|
```
|
|
132
132
|
|
|
133
|
-
Proof is a compact PR, CI run, report, commit range, deployment, or thread reference. Dependencies gate prerequisites; independent tasks are parallel-ready. A coordinator can staff an unassigned ready task with `atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>`; assignment is allowed only once while the task is `todo`. Use `task block` only for a real external blocker, then checkpoint exact state and next action. Once the blocker is resolved, only that coordinator can clear it with `atdd-flow task unblock <project> <task-id> --by <coordinator-address>` while the task remains `in_progress`. For deliveries explicitly governed by the `workflow` ATDD Bun profile, `review → done` additionally requires a durable final behavioral-review result with decision `APPROVE` for the current clean delivery commit. For an idle driver’s final task, `task done ... --retire-assignee` delegates clean-and-merged worktree retirement to ATDD Bun. A driver whose assigned tasks are all already `done` is retired with `atdd-flow seat retire <address> --by <coordinator-or-main>`, using the same checks. When every assigned task is `done` and the recorded worktree is absent from disk and from `git worktree list`, `seat retire` records the retirement without running worktree finish, deleting the branch, or recreating the worktree; a missing worktree that Git still registers fails closed. Both paths fail closed on unfinished tasks or a dirty or unmerged worktree; on success they record `retired` and a checkpoint, then close the driver's Herdr workspace when `close_inactive` is enabled. `main@<project>` may likewise retire a coordinator seat (never main or the operator) once every task it coordinates or is assigned is `done`. A non-main coordinator sharing the primary checkout is retired as a Desk record without worktree finish; other coordinators use the same worktree-finish, absent-and-unregistered, and fail-closed paths. Retirement runs in the seat worktree with ATDD Bun from the primary checkout's `node_modules/.bin`, else the primary checkout's own source when it is the ATDD Bun package, else `PATH`; never the seat worktree's copy, which may pin an older toolkit.
|
|
133
|
+
Proof is a compact PR, CI run, report, commit range, deployment, or thread reference. Dependencies gate prerequisites; independent tasks are parallel-ready. A coordinator can staff an unassigned ready task with `atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>`; assignment is allowed only once while the task is `todo`. Add `--immediate --priority <priority> --mutation-boundary <text>` to persist and natively deliver a task-specific dispatch (task, priority, boundary, exact assignee-only `task start` command); it fails closed, writing nothing, when the assignee has no native queue. The assignee records a typed, dispatch-bound `atdd-flow task acknowledge <project> <task-id> --dispatch <message-id> --by <assignee>` (idempotent, distinct from `task start`; messages, receipts, and heartbeats never count), and the coordinator's on-demand `atdd-flow task observe-assignment` posts at most one `missing_acknowledgement` and one `missing_start` alert to the assignee. Use `task block` only for a real external blocker, then checkpoint exact state and next action. Once the blocker is resolved, only that coordinator can clear it with `atdd-flow task unblock <project> <task-id> --by <coordinator-address>` while the task remains `in_progress`. For deliveries explicitly governed by the `workflow` ATDD Bun profile, `review → done` additionally requires a durable final behavioral-review result with decision `APPROVE` for the current clean delivery commit. For an idle driver’s final task, `task done ... --retire-assignee` delegates clean-and-merged worktree retirement to ATDD Bun. A driver whose assigned tasks are all already `done` is retired with `atdd-flow seat retire <address> --by <coordinator-or-main>`, using the same checks. When every assigned task is `done` and the recorded worktree is absent from disk and from `git worktree list`, `seat retire` records the retirement without running worktree finish, deleting the branch, or recreating the worktree; a missing worktree that Git still registers fails closed. Both paths fail closed on unfinished tasks or a dirty or unmerged worktree; on success they record `retired` and a checkpoint, then close the driver's Herdr workspace when `close_inactive` is enabled. `main@<project>` may likewise retire a coordinator seat (never main or the operator) once every task it coordinates or is assigned is `done`. A non-main coordinator sharing the primary checkout is retired as a Desk record without worktree finish; other coordinators use the same worktree-finish, absent-and-unregistered, and fail-closed paths. Retirement runs in the seat worktree with ATDD Bun from the primary checkout's `node_modules/.bin`, else the primary checkout's own source when it is the ATDD Bun package, else `PATH`; never the seat worktree's copy, which may pin an older toolkit.
|
|
134
134
|
|
|
135
135
|
At 80% context usage a seat writes a structured handover (template and process rules in the lifecycle convention), checks it with `atdd-flow handover <address> --check --file <path>`, and records it with `atdd-flow handover <address> --file <path>`. The seat keeps one durable `handover.yaml` beside its unchanged checkpoint. Recording rejects a missing header, Runtime line, or section, sections out of order, more than 150 lines, vague references such as `latest` or `that PR` without an exact ID, and a `supersedes` value that does not match the recorded handover id. A fresh session bound to the same seat reads it with `atdd-flow handover show <address>`.
|
|
136
136
|
|
|
@@ -192,7 +192,7 @@ atdd-flow multiplexer apply herdr --session forge
|
|
|
192
192
|
atdd-flow multiplexer apply herdr
|
|
193
193
|
```
|
|
194
194
|
|
|
195
|
-
For each Desk project, apply reconciles the declared repository checkout as workspace `{project}` and gives every main
|
|
195
|
+
For each Desk project, apply reconciles the declared repository checkout as workspace `{project}` and gives every main, operator, and non-generic coordinator seat using it a tab and pane named `{seat.address}`. `main@{project}` is the single primary coordinator projection: when it uses the primary checkout, the preserved legacy generic `coordinator@{project}` sharing that checkout is not bulk-projected as a second tab (an explicit per-seat projection still projects it, and a project without a primary main keeps it). Linked coordinator worktrees and active drivers become linked-worktree workspaces named `{seat.address}`, with an equally named tab and pane. A driver is active only while it has an unblocked `in_progress` assigned task or a bound Herdr pane that still runs an agent; never-started, blocked, and finished drivers get no workspace. An explicit per-seat projection such as `pi runtime launch` still projects its own seat. A missing linked worktree is skipped and reported under `unprojected` instead of aborting the Desk. Unbound worktrees are ignored, and apply never focuses or guesses about other sessions.
|
|
196
196
|
|
|
197
197
|
A workspace is stale when it is labelled with a Desk driver seat address, that driver is retired or inactive, and its checkout is a linked worktree of the seat's Desk project or no longer exists. `multiplexer status` reports the `stale` count and, read-only, lists each one under `stale_workspaces` with its `address`, `workspace_id`, `reason` (`retired` or `no active task`), current pane `agents` and `agent_status`, and the `outcome` apply would take (`close` or `keep`, with `because` for a kept workspace). Review that list before running apply. `multiplexer apply` closes stale workspaces only when `desk.yaml` enables it:
|
|
198
198
|
|
|
@@ -250,6 +250,29 @@ atdd-flow --root /path/to/desk attach driver.runtime@example-app --application h
|
|
|
250
250
|
|
|
251
251
|
Pi receives its identity and startup task from the extension itself. Its normal TUI remains visible and manually usable; incoming Desk mail wakes it through Pi's native message API. The runtime lifecycle has one internal bounded inbox transport; it does not add a second watcher, registry, mailbox, reservation system, or routing path.
|
|
252
252
|
|
|
253
|
+
### Wake or migrate live Pi seats
|
|
254
|
+
|
|
255
|
+
`atdd-flow pi wake` replaces the manual `/model` → `/reload` → inbox/task prompt chain with one command per seat or fleet. It sends only the local extension command `/flow-wake` through Herdr (no model prompt) and verifies Pi's own session transcript.
|
|
256
|
+
|
|
257
|
+
```sh
|
|
258
|
+
atdd-flow pi wake driver.runtime@example-app --by operator@desk # keep model, reconcile, wake once
|
|
259
|
+
atdd-flow pi wake driver.runtime@example-app --model-profile restore --by main@example-app # swap to the mapped model first
|
|
260
|
+
atdd-flow pi wake --scope project --by main@example-app # every live seat of the project
|
|
261
|
+
atdd-flow pi wake --scope desk --by operator@desk # every live seat of the Desk
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
`--by` must be `operator@desk` or the project's `main@<project>`; `--scope desk` requires the operator. A profile is declared in `models.yaml` and must map a source `provider/model` to an existing, enabled portfolio candidate that names one exact Pi `--provider`/`--model`; anything else is rejected when `models.yaml` is read:
|
|
265
|
+
|
|
266
|
+
```yaml
|
|
267
|
+
model_migrations:
|
|
268
|
+
restore:
|
|
269
|
+
anthropic/claude-sonnet-4-5: strong
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
The extension re-checks the target against Pi's authenticated model registry, calls `setModel`, preserves the thinking level, and reloads. On reload the unmatched request is reconstructed from the active transcript branch; Flow reactivates mail, writes the result, and injects the recovery bundle plus the authoritative task directive as exactly one resumed-work turn. A seat whose Pi predates `/flow-wake` is first sent one `/reload` at a safe boundary, and only a fresh `flow-wake-capability/v1` transcript entry lets the wake proceed.
|
|
273
|
+
|
|
274
|
+
Seats are handled in lexical order, one at a time, and every seat gets exactly one row: `woke`, `swapped+woke`, `deferred` (agent working), `refused` (blocked/question UI, stale pane/session/process, unmapped model), `skipped` (retired, terminal task, no agent or binding, the invoking seat), or `failed` (unavailable target, partial model/reload evidence; never retried). A named seat exits non-zero unless it woke; a fleet exits non-zero only on `refused` or `failed`. No fallback model, relaunch, compaction, seat reassignment, or session replacement exists, and Desk, seat, runtime and repository records are never written; only Pi's native model-change and flow-wake entries are appended to the transcript. `ATDD_FLOW_WAKE_TIMEOUT_MS` bounds each wait (default 30 s; 5 min for a seat to return to idle after a bootstrap reload).
|
|
275
|
+
|
|
253
276
|
The lifecycle and recovery design was informed by [nicobailon/pi-messenger](https://github.com/nicobailon/pi-messenger) at `09937ed647a1b07a3b595bf75943feacb80ff123` (MIT). No pi-messenger source is copied here. Flow keeps Desk identity, recipient selection, immutable mail, and governance authoritative; any future copied source must carry its MIT notice.
|
|
254
277
|
|
|
255
278
|
With ATDD Bun, enable the Workflow profile:
|
package/extensions/pi/index.ts
CHANGED
|
@@ -4,9 +4,10 @@ import { basename, dirname, join } from "node:path";
|
|
|
4
4
|
import { parse, stringify } from "yaml";
|
|
5
5
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { clearStaleRuntimeState, clearRuntimeState, heartbeatRuntimeState, ownsRuntimeState, recordMailActivation, registerRuntimeState, runtimeOwnerToken, withOwnedRuntimeState } from "../../src/runtime-state";
|
|
7
|
-
import { paths, readYaml, type Seat } from "../../src/core";
|
|
7
|
+
import { modelPortfolio, paths, piModel, readYaml, type Seat } from "../../src/core";
|
|
8
|
+
import { type TranscriptEntry, customEntries, wakeCapabilityType, wakeRequestIdPattern, wakeRequestType, wakeResultType, wakeStartType } from "../../src/pi-wake-protocol";
|
|
8
9
|
import { activationNotice, diagnoseMailActivation, expectsNativeMail } from "../../src/mail-activation-notice";
|
|
9
|
-
import { type NoticeState, createNoticeState, refreshDelivered, sendSettledDirective, sendTaskDirective, shouldNotifyMail } from "../../src/next-action-notices";
|
|
10
|
+
import { type NoticeState, createNoticeState, deliveredDirectiveText, refreshDelivered, sendSettledDirective, sendTaskDirective, shouldNotifyMail } from "../../src/next-action-notices";
|
|
10
11
|
import { handoverThresholdInstruction, runtimeStartupInstruction, successorInstruction } from "../../src/collaboration-policy";
|
|
11
12
|
import { type RecoveryMode, recoveryFor } from "../../src/recovery-bundle";
|
|
12
13
|
import { contextHandoverThreshold, readHandoverReplacement } from "../../src/handover";
|
|
@@ -362,6 +363,49 @@ const unsetOrEqual = (recorded: string | undefined, live: string | undefined) =>
|
|
|
362
363
|
const sessionUnsetOrEqual = (runtime: Seat["runtime"], piSession?: string, piSessionPath?: string) =>
|
|
363
364
|
unsetOrEqual(runtime?.pi_session, piSession) && unsetOrEqual(runtime?.pi_session_path, piSessionPath);
|
|
364
365
|
|
|
366
|
+
|
|
367
|
+
type WakeIdentity = { seat: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string };
|
|
368
|
+
type WakeContext = {
|
|
369
|
+
model?: { provider: string; id: string };
|
|
370
|
+
thinkingLevel?: string;
|
|
371
|
+
modelRegistry?: { find(provider: string, id: string): unknown; hasConfiguredAuth(model: never): boolean };
|
|
372
|
+
sessionManager?: { getSessionId?: () => string; getSessionFile?: () => string | undefined; getBranch?: () => unknown[] };
|
|
373
|
+
reload?: () => Promise<void>;
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
/** The live Pi/Herdr identity: launcher variables first, then Herdr's host-injected pane identity. */
|
|
377
|
+
function wakeIdentity(seat: string, ctx: WakeContext): WakeIdentity {
|
|
378
|
+
const manager = ctx.sessionManager;
|
|
379
|
+
return {
|
|
380
|
+
seat,
|
|
381
|
+
pi_session: process.env.ATDD_FLOW_PI_SESSION ?? manager?.getSessionId?.(),
|
|
382
|
+
pi_session_path: manager?.getSessionFile?.(),
|
|
383
|
+
herdr_session: process.env.ATDD_FLOW_HERDR_SESSION ?? process.env.HERDR_SESSION,
|
|
384
|
+
pane: process.env.ATDD_FLOW_HERDR_PANE ?? process.env.HERDR_PANE_ID,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** A wake may act only for the exact seat, Pi session and Herdr pane that the Desk binds together. */
|
|
389
|
+
async function boundToLiveIdentity(root: string, identity: WakeIdentity) {
|
|
390
|
+
if (!identity.pi_session || !identity.herdr_session || !identity.pane) return false;
|
|
391
|
+
try {
|
|
392
|
+
const runtime = (await readYaml<Seat>(paths(root).seatFile(identity.seat))).runtime;
|
|
393
|
+
const locator = runtime?.addresses.herdr;
|
|
394
|
+
return runtime?.application === "herdr" && typeof locator === "object" && locator.session === identity.herdr_session && locator.pane === identity.pane
|
|
395
|
+
&& unsetOrEqual(runtime.pi_session, identity.pi_session);
|
|
396
|
+
} catch { return false; }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const branchOf = (ctx: WakeContext) => (ctx.sessionManager?.getBranch?.() ?? []) as TranscriptEntry[];
|
|
400
|
+
|
|
401
|
+
/** The single unmatched request on the active branch that was issued for this exact live identity. */
|
|
402
|
+
function unmatchedWakeRequest(branch: TranscriptEntry[], identity: WakeIdentity) {
|
|
403
|
+
const matched = new Set(customEntries(branch, wakeResultType).map((entry) => entry.data?.request_id));
|
|
404
|
+
const request = customEntries(branch, wakeRequestType).map((entry) => entry.data).filter((data) => data && !matched.has(data.request_id)).at(-1);
|
|
405
|
+
if (!request || typeof request.request_id !== "string") return undefined;
|
|
406
|
+
return request.seat === identity.seat && request.pi_session === identity.pi_session && request.pane === identity.pane ? request : undefined;
|
|
407
|
+
}
|
|
408
|
+
|
|
365
409
|
/** The recovery bundle is advisory; any failure falls back to the plain prompt. */
|
|
366
410
|
const recovery = (root: string, seat: string, mode: RecoveryMode, session?: string) => recoveryFor(root, seat, mode, session).catch(() => undefined);
|
|
367
411
|
|
|
@@ -417,7 +461,75 @@ export default function (pi: ExtensionAPI, environment: NodeJS.ProcessEnv = proc
|
|
|
417
461
|
ctx.shutdown();
|
|
418
462
|
} catch { /* No validated replacement request exists for this source session. */ }
|
|
419
463
|
});
|
|
464
|
+
const safeThinking = (ctx?: WakeContext) => ctx?.thinkingLevel ?? (typeof pi.getThinkingLevel === "function" ? pi.getThinkingLevel() : undefined);
|
|
465
|
+
const wakeResult = (requestId: string, outcome: string, extra: Record<string, unknown> = {}) => pi.appendEntry(wakeResultType, { request_id: requestId, outcome, ...extra });
|
|
466
|
+
// Request, optional authenticated model swap, thinking restore, then reload; the post-reload session_start writes the result and the one resumed-work turn.
|
|
467
|
+
// The command is registered only on hosts that expose it; `/reload` keeps the old instance terminal, so nothing runs after it here.
|
|
468
|
+
(pi as { registerCommand?: ExtensionAPI["registerCommand"] }).registerCommand?.("flow-wake", {
|
|
469
|
+
description: "Flow: optionally swap to a mapped model, reload, reconcile, and wake this seat once",
|
|
470
|
+
handler: async (args: string, rawContext: unknown) => {
|
|
471
|
+
const ctx = rawContext as WakeContext & { hasUI?: boolean; ui?: { notify(message: string, level: string): void } };
|
|
472
|
+
const words = args.trim().split(/\s+/).filter(Boolean);
|
|
473
|
+
const requestId = words.at(-1);
|
|
474
|
+
if (words.length < 1 || words.length > 2 || !requestId || !wakeRequestIdPattern.test(requestId)) {
|
|
475
|
+
if (ctx.hasUI) ctx.ui?.notify("Usage: /flow-wake [portfolio-candidate] <request-id>", "error");
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const candidate = words.length === 2 ? words[0]! : undefined;
|
|
479
|
+
const identity = wakeIdentity(seat, ctx);
|
|
480
|
+
if (customEntries(branchOf(ctx), wakeRequestType).some((entry) => entry.data?.request_id === requestId)) {
|
|
481
|
+
wakeResult(requestId, "failed", { reason: "duplicate request id" });
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const source = { provider: ctx.model?.provider, model: ctx.model?.id, thinking: safeThinking(ctx) };
|
|
485
|
+
pi.appendEntry(wakeRequestType, { request_id: requestId, ...identity, ...(candidate ? { candidate } : {}), source });
|
|
486
|
+
try {
|
|
487
|
+
if (!await boundToLiveIdentity(root, identity)) return wakeResult(requestId, "failed", { reason: "Desk binding does not match the live seat, session and pane" });
|
|
488
|
+
if (candidate) {
|
|
489
|
+
const entry = (await modelPortfolio(root))?.models.find((model) => model.id === candidate);
|
|
490
|
+
const exact = entry && entry.enabled !== false ? piModel(entry) : undefined;
|
|
491
|
+
if (!exact) return wakeResult(requestId, "failed", { reason: `candidate ${candidate} is not an enabled exact Pi model` });
|
|
492
|
+
const model = ctx.modelRegistry?.find(exact.provider, exact.model);
|
|
493
|
+
if (!model || !ctx.modelRegistry!.hasConfiguredAuth(model as never)) return wakeResult(requestId, "failed", { reason: `${exact.provider}/${exact.model} is not available and authenticated in Pi` });
|
|
494
|
+
const thinking = pi.getThinkingLevel();
|
|
495
|
+
if (!await pi.setModel(model as never)) return wakeResult(requestId, "failed", { reason: `Pi did not accept ${exact.provider}/${exact.model}` });
|
|
496
|
+
pi.setThinkingLevel(thinking);
|
|
497
|
+
}
|
|
498
|
+
await ctx.reload?.();
|
|
499
|
+
} catch (error) {
|
|
500
|
+
wakeResult(requestId, "failed", { reason: error instanceof Error ? error.message : String(error) });
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
});
|
|
420
504
|
pi.on("session_start", async (_event, ctx) => {
|
|
505
|
+
const wakeContext = ctx as unknown as WakeContext;
|
|
506
|
+
const identity = wakeIdentity(seat, wakeContext);
|
|
507
|
+
const wake = unmatchedWakeRequest(branchOf(wakeContext), identity);
|
|
508
|
+
// Capability is evidence of a verified activation under this extension version; it never lives in runtime state.
|
|
509
|
+
const announceCapability = () => { try { pi.appendEntry(wakeCapabilityType, { seat, pi_session: identity.pi_session, pane: identity.pane }); } catch { /* The host re-checks capability; absence only forces a reload. */ } };
|
|
510
|
+
// One reload-reconstructed result and one combined recovery + task-directive turn replace the plain startup message.
|
|
511
|
+
let wakeSettled = false;
|
|
512
|
+
const completeWake = async () => {
|
|
513
|
+
if (!wake) return false;
|
|
514
|
+
wakeSettled = true;
|
|
515
|
+
const requestId = String(wake.request_id), candidate = typeof wake.candidate === "string" ? wake.candidate : undefined;
|
|
516
|
+
const target = { provider: wakeContext.model?.provider, model: wakeContext.model?.id, thinking: safeThinking(wakeContext) };
|
|
517
|
+
if (candidate) {
|
|
518
|
+
const entry = (await modelPortfolio(root).catch(() => undefined))?.models.find((model) => model.id === candidate);
|
|
519
|
+
const exact = entry && entry.enabled !== false ? piModel(entry) : undefined;
|
|
520
|
+
if (!exact || exact.provider !== target.provider || exact.model !== target.model) { wakeResult(requestId, "failed", { reason: "model after reload is not the requested candidate", target }); return true; }
|
|
521
|
+
}
|
|
522
|
+
const bundle = await recovery(root, seat, "start", identity.pi_session);
|
|
523
|
+
const directive = await deliveredDirectiveText(root, seat, notices);
|
|
524
|
+
wakeResult(requestId, candidate ? "swapped+woke" : "woke", { target });
|
|
525
|
+
pi.sendMessage({
|
|
526
|
+
customType: wakeStartType,
|
|
527
|
+
content: `${runtimeStartupInstruction(seat, bundle)}${directive ? `\n${directive}` : ""}`,
|
|
528
|
+
display: true, details: { seat, root, request_id: requestId, wake: true },
|
|
529
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
530
|
+
return true;
|
|
531
|
+
};
|
|
532
|
+
const failWake = (reason: string) => { if (wake && !wakeSettled) { wakeSettled = true; wakeResult(String(wake.request_id), "failed", { reason }); } };
|
|
421
533
|
const sessionManager = (ctx as { sessionManager?: { getSessionId?: () => string; getSessionFile?: () => string | undefined } }).sessionManager;
|
|
422
534
|
const liveSession = environment.ATDD_FLOW_PI_SESSION ?? sessionManager?.getSessionId?.();
|
|
423
535
|
// One display-only notice per session: a refused, timed-out or failed activation must never be silent, and never wakes the agent.
|
|
@@ -456,10 +568,13 @@ export default function (pi: ExtensionAPI, environment: NodeJS.ProcessEnv = proc
|
|
|
456
568
|
if (await hostAttachedActivation({ root, seat, herdrSession: environment.HERDR_SESSION, pane: environment.HERDR_PANE_ID, piSession: sessionManager?.getSessionId?.(), piSessionPath: sessionManager?.getSessionFile?.() })) {
|
|
457
569
|
hostActivatedAt = Date.now();
|
|
458
570
|
if (await runtime.activate()) {
|
|
571
|
+
announceCapability();
|
|
572
|
+
if (await completeWake()) return;
|
|
459
573
|
const bundle = await recovery(root, seat, "start", sessionManager?.getSessionId?.());
|
|
460
574
|
const notice = "Notices created before this session were not replayed; run atdd-flow status seat " + seat + " for unanswered mail.";
|
|
461
575
|
pi.sendMessage({ customType: "atdd-flow-start", content: `${runtimeStartupInstruction(seat, bundle)} ${notice}`, display: true, details: { seat, root } }, { triggerTurn: true, deliverAs: "followUp" });
|
|
462
576
|
}
|
|
577
|
+
else failWake("native mail activation was refused");
|
|
463
578
|
} else if (await expectsNativeMail(root, seat)) await noticeActivation("inactive");
|
|
464
579
|
return;
|
|
465
580
|
}
|
|
@@ -468,7 +583,12 @@ export default function (pi: ExtensionAPI, environment: NodeJS.ProcessEnv = proc
|
|
|
468
583
|
void awaitLaunchActivation({
|
|
469
584
|
root, seat, piSession, herdrSession, pane,
|
|
470
585
|
activate: async () => {
|
|
471
|
-
if (!await activateWithRetry(() => current.activate())) return;
|
|
586
|
+
if (!await activateWithRetry(() => current.activate())) return failWake("native mail activation was refused");
|
|
587
|
+
announceCapability();
|
|
588
|
+
if (await completeWake()) {
|
|
589
|
+
if (ctx.hasUI) ctx.ui.notify(`ATDD Flow native mail active for ${seat}`, "info");
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
472
592
|
const request = await readHandoverReplacement(root, seat).catch(() => undefined);
|
|
473
593
|
const successor = request && request.source.pi_session !== piSession ? request : undefined;
|
|
474
594
|
const bundle = await recovery(root, seat, successor ? "successor" : "start", piSession);
|
|
@@ -483,7 +603,7 @@ export default function (pi: ExtensionAPI, environment: NodeJS.ProcessEnv = proc
|
|
|
483
603
|
await sendTaskDirective(host, root, seat, notices);
|
|
484
604
|
if (ctx.hasUI) ctx.ui.notify(`ATDD Flow native mail active for ${seat}`, "info");
|
|
485
605
|
},
|
|
486
|
-
}).catch((error) => error instanceof ActivationTimeout
|
|
606
|
+
}).catch((error) => (failWake(error instanceof Error ? error.message : String(error)), error instanceof ActivationTimeout)
|
|
487
607
|
? noticeActivation("inactive")
|
|
488
608
|
: noticeActivation(current.active ? "active" : "inactive", error instanceof Error ? error.message : String(error)));
|
|
489
609
|
});
|
package/package.json
CHANGED
package/src/address-book.ts
CHANGED
|
@@ -26,11 +26,11 @@ export async function addressBook(root: string) {
|
|
|
26
26
|
catch { /* A partially removed legacy seat is not an address-book entry. */ }
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
process.stdout.write("# Desk address book\n\n");
|
|
30
|
+
process.stdout.write("`operator@desk` is the human authority. Drivers route normal work through coordinators; main seats are the normal technical gateway to the operator.\n\n");
|
|
31
|
+
process.stdout.write("| Address | Identity | Purpose / authority |\n| --- | --- | --- |\n");
|
|
32
32
|
for (const record of records.sort((left, right) => left.address.localeCompare(right.address))) {
|
|
33
33
|
const purpose = (record.purpose ?? authority(record)).replaceAll("|", "\\|");
|
|
34
|
-
|
|
34
|
+
process.stdout.write(`| ${record.address} | ${identity(record.address)} | ${purpose} |\n`);
|
|
35
35
|
}
|
|
36
36
|
}
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
// URN: component:flow:e006:adoption-authorizations:backend:integration
|
|
2
|
+
// Tested-By:
|
|
3
|
+
// - test:flow:exact-adoption-resumed-proof-and-relaunch
|
|
4
|
+
// Runtime: bun
|
|
5
|
+
// Purpose: Create and resolve exact-session adoption authorizations.
|
|
6
|
+
|
|
1
7
|
import { createHash } from "node:crypto";
|
|
2
8
|
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
3
9
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -44,7 +50,7 @@ export async function createExactSessionAdoptionAuthorization(root: string, id:
|
|
|
44
50
|
await mkdir(dirname(file), { recursive: true });
|
|
45
51
|
await writeFile(file, content, { encoding: "utf8", flag: "wx" });
|
|
46
52
|
await writeFile(hashPath(root, id), `${digest(content)}\n`, { encoding: "utf8", flag: "wx" });
|
|
47
|
-
|
|
53
|
+
process.stdout.write(`${id}\n`);
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
function same(record: ExactSessionAdoptionAuthorization, expected: Omit<ExactSessionAdoptionAuthorization, "schema" | "id" | "issued_by" | "created_at">) {
|
package/src/cli.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { basename, resolve } from "node:path";
|
|
9
9
|
import { init, initProject, configureProject, migrateNamedCoordinatorTopology, spawn, bind, useApplication, attach, describe, checkpoint, launchPiRuntime, migrate, openSeat, piExtensionPath } from "./seats";
|
|
10
|
+
import { formatWakeRow, wakePi } from "./pi-wake";
|
|
10
11
|
import { createExactSessionAdoptionAuthorization } from "./adoption-authorizations";
|
|
11
12
|
import { addParticipant, openThread, post, readMessage, receipt, result, startThread } from "./threads";
|
|
12
13
|
import * as tasks from "./tasks";
|
|
@@ -34,12 +35,13 @@ Usage:
|
|
|
34
35
|
atdd-flow desk migrate
|
|
35
36
|
atdd-flow project init <project>
|
|
36
37
|
atdd-flow project configure <project> [--vcs <git|jj>] [--repository <primary-checkout>] [--worktree-root <directory>]
|
|
37
|
-
atdd-flow project migrate-vcs <project> --to jj [--allow-dirty-primary] [--dry-run]
|
|
38
|
+
atdd-flow project migrate-vcs <project> --to jj [--allow-dirty-primary [--retain-large-primary-file <path>:<sha256>:<bytes>:<mode> ...]] [--dry-run]
|
|
38
39
|
atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>
|
|
39
40
|
atdd-flow spawn <project> <role> <name> [--worktree <path>] [--branch <branch>] [--agent <legacy-executable>]
|
|
40
41
|
atdd-flow bind <address> [--application <application>] --address <native-address> [--session <name>] [--agent <executable>] [--worktree <path>] [--wake host|native]
|
|
41
42
|
atdd-flow attach <address> [--application <application>] [--wake host|native]
|
|
42
43
|
atdd-flow pi extension-path
|
|
44
|
+
atdd-flow pi wake <seat>|--scope project|desk [--model-profile <name>] --by <authorized-seat>
|
|
43
45
|
atdd-flow pi runtime authorize-adoption <id> --by operator@desk --seat <seat> --pi-session <id> --pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --target-herdr-session <session> --target-pane <pane> --target-cwd <path>
|
|
44
46
|
atdd-flow pi runtime launch <address> [--pane <asserted-herdr-pane>] --herdr-session <session> [--resume] [--dry-run] [--adopt-session <exact-jsonl> --source-herdr-session <session> --source-pane <pane> --authorization <record-id>]
|
|
45
47
|
atdd-flow application use <address> <application>
|
|
@@ -48,10 +50,12 @@ Usage:
|
|
|
48
50
|
atdd-flow checkpoint <address> --summary <text> --next <text> [--status active|standby|blocked|complete|unverified]
|
|
49
51
|
atdd-flow handover <address> --file <path> [--check]
|
|
50
52
|
atdd-flow handover show <address>
|
|
51
|
-
atdd-flow handover replace <address> [--supersede-consumed] [--legacy-source-bootstrap] --handover <id> --authorization <operator-record> --takeover-task <id> --source-pi-session <id> --source-pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --herdr-session <session> --target-pane <pane> --target-cwd <canonical-main-worktree>
|
|
53
|
+
atdd-flow handover replace <address> [--supersede-consumed] [--recover-stale-main] [--legacy-source-bootstrap] --handover <id> --authorization <operator-record> --takeover-task <id> --source-pi-session <id> --source-pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --herdr-session <session> --target-pane <pane> --target-cwd <canonical-main-worktree>
|
|
52
54
|
atdd-flow handover resumed <address>
|
|
53
55
|
atdd-flow task add <project> <task-id> --title <text> --coordinator <address> [--assignee <address>] [--body <text>] [--source <reference>] [--depends-on <task-id,...>] --done-when <text> [--done-when <text> ...]
|
|
54
|
-
atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>
|
|
56
|
+
atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address> [--immediate --priority <priority> --mutation-boundary <text>]
|
|
57
|
+
atdd-flow task acknowledge <project> <task-id> --dispatch <message-id> --by <assignee>
|
|
58
|
+
atdd-flow task observe-assignment <project> <task-id> --by <coordinator-address>
|
|
55
59
|
atdd-flow task transfer <project> <task-id> --to <main-or-named-coordinator> --reason <text> --by <actor> [--authorization <operator-message-id>]
|
|
56
60
|
atdd-flow task amend <project> <task-id> [--title <text>] [--body <text>] [--source <reference>] [--depends-on <task-id,...>]
|
|
57
61
|
atdd-flow task import <project> <task-id> --proof <reference> [--proof <reference> ...] [--done-when <text> ...] [--source <reference>]
|
|
@@ -115,13 +119,19 @@ async function main() {
|
|
|
115
119
|
if (rest[0] === "configure") return configureProject(root, required(rest[1], "project"), rest.slice(2));
|
|
116
120
|
if (rest[0] === "migrate-vcs") return migrateProjectVcs(root, required(rest[1], "project"), rest.slice(2));
|
|
117
121
|
if (rest[0] === "migrate-named-coordinator-topology") return migrateNamedCoordinatorTopology(root, required(rest[1], "project"), rest.slice(2));
|
|
118
|
-
throw new Error("Use `atdd-flow project init <project>`, `atdd-flow project configure <project> [--vcs <git|jj>] [--repository <primary-checkout>] [--worktree-root <directory>]`, `atdd-flow project migrate-vcs <project> --to jj [--allow-dirty-primary] [--dry-run]`, or `atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>`.");
|
|
122
|
+
throw new Error("Use `atdd-flow project init <project>`, `atdd-flow project configure <project> [--vcs <git|jj>] [--repository <primary-checkout>] [--worktree-root <directory>]`, `atdd-flow project migrate-vcs <project> --to jj [--allow-dirty-primary [--retain-large-primary-file <path>:<sha256>:<bytes>:<mode> ...]] [--dry-run]`, or `atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>`.");
|
|
119
123
|
},
|
|
120
124
|
spawn: () => spawn(root, required(rest[0], "project"), required(rest[1], "role"), required(rest[2], "name"), rest.slice(3)),
|
|
121
125
|
bind: () => bind(root, required(rest[0], "address"), rest.slice(1)),
|
|
122
126
|
attach: () => attach(root, required(rest[0], "address"), rest.slice(1)),
|
|
123
127
|
pi: async () => {
|
|
124
128
|
if (rest[0] === "extension-path") return console.log(piExtensionPath());
|
|
129
|
+
if (rest[0] === "wake") {
|
|
130
|
+
const { rows, success } = await wakePi(root, rest.slice(1), (entry) => console.log(formatWakeRow(entry)));
|
|
131
|
+
if (!success) process.exitCode = 1;
|
|
132
|
+
if (rows.length !== 1) console.log(`pi wake: ${rows.length} seat(s) — ${["woke", "swapped+woke", "deferred", "refused", "skipped", "failed"].map((outcome) => [outcome, rows.filter((entry) => entry.outcome === outcome).length] as const).filter(([, count]) => count).map(([outcome, count]) => `${count} ${outcome}`).join(", ") || "none"}`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
125
135
|
if (rest[0] === "runtime" && rest[1] === "authorize-adoption") return createExactSessionAdoptionAuthorization(root, required(rest[2], "authorization id"), rest.slice(3));
|
|
126
136
|
if (rest[0] === "runtime" && rest[1] === "launch") {
|
|
127
137
|
const plan = await launchPiRuntime(root, required(rest[2], "address"), rest.slice(3));
|
|
@@ -129,7 +139,7 @@ async function main() {
|
|
|
129
139
|
else console.log(`Launched Pi runtime for ${required(rest[2], "address")} with ${plan.candidate}.`);
|
|
130
140
|
return;
|
|
131
141
|
}
|
|
132
|
-
throw new Error("Use `pi extension-path`, `pi runtime authorize-adoption <id> ...`, or `pi runtime launch <address> [--pane <asserted-pane>] --herdr-session <session> [--resume] [--dry-run] [--adopt-session <exact-jsonl> --source-herdr-session <session> --source-pane <pane> --authorization <record-id>]`.");
|
|
142
|
+
throw new Error("Use `pi extension-path`, `pi wake <seat>|--scope project|desk [--model-profile <name>] --by <authorized-seat>`, `pi runtime authorize-adoption <id> ...`, or `pi runtime launch <address> [--pane <asserted-pane>] --herdr-session <session> [--resume] [--dry-run] [--adopt-session <exact-jsonl> --source-herdr-session <session> --source-pane <pane> --authorization <record-id>]`.");
|
|
133
143
|
},
|
|
134
144
|
application: async () => {
|
|
135
145
|
if (rest[0] === "use") return useApplication(root, required(rest[1], "address"), required(rest[2], "application"));
|
|
@@ -146,6 +156,8 @@ async function main() {
|
|
|
146
156
|
const [subcommand, projectName, taskId, ...tail] = rest;
|
|
147
157
|
if (subcommand === "add") return tasks.add(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
148
158
|
if (subcommand === "assign") return tasks.assign(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
159
|
+
if (subcommand === "acknowledge") return tasks.acknowledge(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
160
|
+
if (subcommand === "observe-assignment") return tasks.observeAssignment(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
149
161
|
if (subcommand === "transfer") return tasks.transfer(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
150
162
|
if (subcommand === "amend") return tasks.amend(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
151
163
|
if (subcommand === "import") return tasks.importCompleted(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
@@ -163,7 +175,7 @@ async function main() {
|
|
|
163
175
|
if (subcommand === "unblock") return tasks.unblock(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
164
176
|
if (subcommand === "list") return tasks.list(root, required(projectName, "project"), rest.slice(2));
|
|
165
177
|
if (subcommand === "open") return tasks.open(root, required(projectName, "project"), required(taskId, "task id"));
|
|
166
|
-
throw new Error("Use `atdd-flow task add|assign|transfer|amend|import|start|prove|handoff|respond|review|return|done|block|supersede|reject|defer|unblock|list|open`.");
|
|
178
|
+
throw new Error("Use `atdd-flow task add|assign|acknowledge|observe-assignment|transfer|amend|import|start|prove|handoff|respond|review|return|done|block|supersede|reject|defer|unblock|list|open`.");
|
|
167
179
|
},
|
|
168
180
|
seat: async () => {
|
|
169
181
|
if (rest[0] === "retire") return tasks.retireSeat(root, required(rest[1], "address"), rest.slice(2));
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
// URN: component:flow:e004:collaboration-policy:backend:application
|
|
2
|
+
// Tested-By:
|
|
3
|
+
// - test:atdd-flow:seat-recovery-bundle
|
|
4
|
+
// Runtime: bun
|
|
5
|
+
// Purpose: Define the project collaboration policy and its startup instruction.
|
|
6
|
+
|
|
1
7
|
import { readFileSync } from "node:fs";
|
|
2
8
|
import { readFile, writeFile } from "node:fs/promises";
|
|
3
9
|
import { join } from "node:path";
|
package/src/core.ts
CHANGED
|
@@ -39,6 +39,8 @@ export type ModelPortfolio = {
|
|
|
39
39
|
schema: "atdd-workflow/models/v1";
|
|
40
40
|
/** Ordered strongest to weakest. Workflow prefers the weakest sufficient enabled candidate. */
|
|
41
41
|
models: ModelCandidate[];
|
|
42
|
+
/** Named, machine-readable migrations: profile -> source `provider/model` -> existing enabled candidate id. */
|
|
43
|
+
model_migrations?: Record<string, Record<string, string>>;
|
|
42
44
|
};
|
|
43
45
|
type LegacyCoordination = { schema: "atdd-workflow/coordination/v2"; site: string; application: string; aliases?: Record<string, string> };
|
|
44
46
|
export type Project = {
|
|
@@ -262,6 +264,40 @@ export async function migrateDesk(root: string) {
|
|
|
262
264
|
return true;
|
|
263
265
|
}
|
|
264
266
|
|
|
267
|
+
/** The one exact Pi provider/model a candidate selects; undefined when it is not Pi or is ambiguous. */
|
|
268
|
+
export function piModel(candidate: ModelCandidate): { provider: string; model: string } | undefined {
|
|
269
|
+
if (basename(candidate.executable) !== "pi") return undefined;
|
|
270
|
+
const args = candidate.args ?? [];
|
|
271
|
+
const flagValues = (flag: string) => args.flatMap((argument, index) => argument === flag ? [args[index + 1]] : []);
|
|
272
|
+
const [provider, ...otherProviders] = flagValues("--provider"), [model, ...otherModels] = flagValues("--model");
|
|
273
|
+
if (otherProviders.length || otherModels.length) return undefined;
|
|
274
|
+
const exact = (value: string | undefined): value is string => Boolean(value?.trim()) && !value!.startsWith("--");
|
|
275
|
+
return exact(provider) && exact(model) ? { provider, model } : undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function assertMigrationTarget(where: string, target: unknown, models: ModelCandidate[]) {
|
|
279
|
+
const candidate = models.find((entry) => entry.id === target);
|
|
280
|
+
if (!candidate) throw new Error(`${where} targets an unknown candidate: ${String(target)}`);
|
|
281
|
+
if (candidate.enabled === false) throw new Error(`${where} targets a disabled candidate: ${candidate.id}`);
|
|
282
|
+
if (!piModel(candidate)) throw new Error(`${where} targets a candidate without one exact Pi provider and model: ${candidate.id}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const isRecord = (entry: unknown): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry);
|
|
286
|
+
|
|
287
|
+
function assertMigration(profile: string, migration: unknown, models: ModelCandidate[]) {
|
|
288
|
+
if (!profile.trim() || !isRecord(migration)) throw new Error(`model_migrations.${profile || "<empty>"} must be a named map of source provider/model to a candidate id.`);
|
|
289
|
+
for (const [source, target] of Object.entries(migration)) {
|
|
290
|
+
if (!/^[^/\s]+\/\S+$/.test(source)) throw new Error(`model_migrations.${profile}: source must be provider/model: ${source}`);
|
|
291
|
+
assertMigrationTarget(`model_migrations.${profile}.${source}`, target, models);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function assertModelMigrations(value: unknown, models: ModelCandidate[]) {
|
|
296
|
+
if (value === undefined) return;
|
|
297
|
+
if (!isRecord(value)) throw new Error("model_migrations must map profile names to migrations.");
|
|
298
|
+
for (const [profile, migration] of Object.entries(value)) assertMigration(profile, migration, models);
|
|
299
|
+
}
|
|
300
|
+
|
|
265
301
|
export async function modelPortfolio(root: string): Promise<ModelPortfolio | undefined> {
|
|
266
302
|
const file = paths(root).models;
|
|
267
303
|
if (!await exists(file)) return undefined;
|
|
@@ -274,6 +310,7 @@ export async function modelPortfolio(root: string): Promise<ModelPortfolio | und
|
|
|
274
310
|
if (ids.has(model.id)) throw new Error(`Duplicate model id in models.yaml: ${model.id}`);
|
|
275
311
|
ids.add(model.id);
|
|
276
312
|
}
|
|
313
|
+
assertModelMigrations(value.model_migrations, value.models);
|
|
277
314
|
return value;
|
|
278
315
|
}
|
|
279
316
|
|
|
@@ -10,6 +10,7 @@ import { basename, join, resolve } from "node:path";
|
|
|
10
10
|
import { type Seat, paths, readYaml, yaml } from "./core";
|
|
11
11
|
import type { HandoverReplacementRequest } from "./handover";
|
|
12
12
|
import { readRuntimeState } from "./runtime-state";
|
|
13
|
+
import { archivedReceiptMatches, recoveryEvidenceMatches } from "./stale-main-recovery";
|
|
13
14
|
import type { Task } from "./tasks";
|
|
14
15
|
|
|
15
16
|
type ReplacementLaunchReceipt = {
|
|
@@ -90,6 +91,9 @@ function validConsumedRequest(request: HandoverReplacementRequest, seat: string)
|
|
|
90
91
|
|
|
91
92
|
/** Rebuild and compare every archived supersession byte and identity. */
|
|
92
93
|
async function exactArchiveMatches(request: HandoverReplacementRequest) {
|
|
94
|
+
// Stale-main recovery proves its evidence by the hash-archived stale receipt, not a consumed request.
|
|
95
|
+
const recovery = request.stale_main_recovery;
|
|
96
|
+
if (recovery) return recoveryEvidenceMatches(request, recovery) && await archivedReceiptMatches(request, recovery);
|
|
93
97
|
const proof = request.supersession;
|
|
94
98
|
if (!proof) return false;
|
|
95
99
|
try {
|
|
@@ -147,7 +151,7 @@ async function uniqueAdoptionReceipt(root: string, authorizationId: string, expe
|
|
|
147
151
|
function exactAdoptionRuntimeMatches(record: Seat, request: HandoverReplacementRequest) {
|
|
148
152
|
const runtime = record.runtime, locator = runtime?.addresses.herdr;
|
|
149
153
|
const observed = {
|
|
150
|
-
supersession: Boolean(request.supersession), application: runtime?.application, wake: runtime?.wake,
|
|
154
|
+
supersession: Boolean(request.supersession || request.stale_main_recovery), application: runtime?.application, wake: runtime?.wake,
|
|
151
155
|
session: runtime?.pi_session, sessionPath: runtime?.pi_session_path,
|
|
152
156
|
herdrSession: typeof locator === "string" ? undefined : locator?.session,
|
|
153
157
|
pane: typeof locator === "string" ? undefined : locator?.pane,
|
package/src/handover.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { resolveExactSessionAdoptionAuthorization } from "./adoption-authorizati
|
|
|
14
14
|
import { exactFailedAdoptionProof } from "./exact-adoption-recovery";
|
|
15
15
|
import { isExactOperatorSeat, operatorActivation, operatorRelaunchSection } from "./operator-launch";
|
|
16
16
|
import { readRuntimeState } from "./runtime-state";
|
|
17
|
+
import { type StaleMainRecovery, buildStaleMainRecovery, replaceActiveRequest, staleRecoveryMatches } from "./stale-main-recovery";
|
|
17
18
|
|
|
18
19
|
export type Handover = {
|
|
19
20
|
schema: "atdd-workflow/handover/v1";
|
|
@@ -119,10 +120,11 @@ export type HandoverReplacementRequest = {
|
|
|
119
120
|
canonical_source: { pi_session: string; pi_session_path: string; herdr_session: string; pane: string; cwd: string; launch_receipt: string };
|
|
120
121
|
live_source: LiveSourceProof;
|
|
121
122
|
};
|
|
123
|
+
stale_main_recovery?: StaleMainRecovery;
|
|
122
124
|
created_at: string;
|
|
123
125
|
};
|
|
124
126
|
|
|
125
|
-
type LiveSourceProof = {
|
|
127
|
+
export type LiveSourceProof = {
|
|
126
128
|
authorization_hash: string;
|
|
127
129
|
pi_session: string;
|
|
128
130
|
pi_session_path: string;
|
|
@@ -204,6 +206,7 @@ function supersessionEvidenceMatches(request: HandoverReplacementRequest, proof:
|
|
|
204
206
|
}
|
|
205
207
|
|
|
206
208
|
export function replacementMatchesRuntime(runtime: Runtime | undefined, request: HandoverReplacementRequest) {
|
|
209
|
+
if (request.stale_main_recovery) return staleRecoveryMatches(runtime, request, request.stale_main_recovery);
|
|
207
210
|
const proof = request.supersession;
|
|
208
211
|
if (proof) return supersessionPlacementMatches(runtime, proof) && supersessionSessionMatches(runtime, proof) && supersessionEvidenceMatches(request, proof);
|
|
209
212
|
if (!request.legacy_source) return canonicalSourceMatchesRuntime(runtime, request.source);
|
|
@@ -360,10 +363,11 @@ async function publishCreateOnly(file: string, content: string) {
|
|
|
360
363
|
} finally { await rm(temporary, { force: true }); }
|
|
361
364
|
}
|
|
362
365
|
|
|
363
|
-
const replacementFlags = ["--supersede-consumed", "--legacy-source-bootstrap", "--handover", "--authorization", "--takeover-task", "--source-pi-session", "--source-pi-session-path", "--source-herdr-session", "--source-pane", "--herdr-session", "--target-pane", "--target-cwd"];
|
|
366
|
+
const replacementFlags = ["--supersede-consumed", "--recover-stale-main", "--legacy-source-bootstrap", "--handover", "--authorization", "--takeover-task", "--source-pi-session", "--source-pi-session-path", "--source-herdr-session", "--source-pane", "--herdr-session", "--target-pane", "--target-cwd"];
|
|
364
367
|
function validateReplacementArgs(args: string[]) {
|
|
365
|
-
if (args.some((arg) => arg.startsWith("--") && !replacementFlags.includes(arg))) throw new Error("Use `handover replace <main-seat> [--supersede-consumed] [--legacy-source-bootstrap] --handover <id> --authorization <operator-record> --takeover-task <id> --source-pi-session <id> --source-pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --herdr-session <session> --target-pane <pane> --target-cwd <canonical-main-worktree>`.");
|
|
368
|
+
if (args.some((arg) => arg.startsWith("--") && !replacementFlags.includes(arg))) throw new Error("Use `handover replace <main-seat> [--supersede-consumed] [--recover-stale-main] [--legacy-source-bootstrap] --handover <id> --authorization <operator-record> --takeover-task <id> --source-pi-session <id> --source-pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --herdr-session <session> --target-pane <pane> --target-cwd <canonical-main-worktree>`.");
|
|
366
369
|
if (has(args, "--supersede-consumed") && has(args, "--legacy-source-bootstrap")) throw new Error("Consumed replacement supersession cannot use legacy source bootstrap.");
|
|
370
|
+
if (has(args, "--recover-stale-main") && (has(args, "--supersede-consumed") || has(args, "--legacy-source-bootstrap"))) throw new Error("Stale main recovery cannot be combined with consumed supersession or legacy source bootstrap.");
|
|
367
371
|
}
|
|
368
372
|
|
|
369
373
|
async function currentReplacementHandover(root: string, resolved: string, args: string[]) {
|
|
@@ -394,13 +398,13 @@ function assertReplacementSource(record: Seat, source: HandoverReplacementReques
|
|
|
394
398
|
|
|
395
399
|
async function prepareReplacement(root: string, resolved: string, args: string[]) {
|
|
396
400
|
validateReplacementArgs(args);
|
|
397
|
-
const legacy = has(args, "--legacy-source-bootstrap"), supersede = has(args, "--supersede-consumed");
|
|
401
|
+
const legacy = has(args, "--legacy-source-bootstrap"), supersede = has(args, "--supersede-consumed"), recover = has(args, "--recover-stale-main");
|
|
398
402
|
const handover = await currentReplacementHandover(root, resolved, args);
|
|
399
403
|
const task = nonEmpty(words(args, "--takeover-task"), "--takeover-task");
|
|
400
404
|
const { record } = await exactMainTakeoverTask(root, resolved, task);
|
|
401
405
|
const source = replacementSource(args), target = replacementTarget(args, record, source);
|
|
402
|
-
if (!supersede) assertReplacementSource(record, source, legacy);
|
|
403
|
-
return { legacy, supersede, handover, task, record, source, target };
|
|
406
|
+
if (!supersede && !recover) assertReplacementSource(record, source, legacy);
|
|
407
|
+
return { legacy, supersede, recover, handover, task, record, source, target };
|
|
404
408
|
}
|
|
405
409
|
|
|
406
410
|
function replacementRequest(resolved: string, intent: Awaited<ReturnType<typeof prepareReplacement>>, authorization: { id: string; hash: string }, legacySource: HandoverReplacementRequest["legacy_source"], dependencies: HandoverReplacementDependencies): HandoverReplacementRequest {
|
|
@@ -735,14 +739,15 @@ export async function requestHandoverReplacement(root: string, address: string,
|
|
|
735
739
|
const resolved = await canonicalAddress(root, address), intent = await prepareReplacement(root, resolved, args);
|
|
736
740
|
const file = handoverReplacementPath(root, resolved);
|
|
737
741
|
if (intent.supersede) return supersedeConsumedReplacement(root, resolved, file, intent, args, dependencies);
|
|
738
|
-
if (await exists(file)) throw new Error(`Path-A replacement request already exists for ${resolved}.`);
|
|
742
|
+
if (!intent.recover && await exists(file)) throw new Error(`Path-A replacement request already exists for ${resolved}.`);
|
|
739
743
|
const authorization = await resolveExactSessionAdoptionAuthorization(root, nonEmpty(words(args, "--authorization"), "--authorization"), {
|
|
740
744
|
seat: resolved, pi_session: intent.source.pi_session, pi_session_path: intent.source.pi_session_path,
|
|
741
745
|
source: { herdr_session: intent.source.herdr_session, pane: intent.source.pane }, target: intent.target,
|
|
742
746
|
});
|
|
743
747
|
const legacySource = intent.legacy ? await verifyLegacySource(intent.source, intent.target, authorization.hash, dependencies) : undefined;
|
|
744
|
-
const
|
|
745
|
-
|
|
748
|
+
const recovery = intent.recover ? await buildStaleMainRecovery(root, resolved, intent, { file, isLive: (prior) => replacementMatchesRuntime(intent.record.runtime, prior) }, (canonical) => verifyLegacySource(intent.source, intent.target, authorization.hash, dependencies, canonical)) : undefined;
|
|
749
|
+
const request = { ...replacementRequest(resolved, intent, authorization, legacySource, dependencies), ...(recovery ? { stale_main_recovery: recovery } : {}) };
|
|
750
|
+
await (recovery?.prior_request ? replaceActiveRequest : dependencies.publish ?? publishCreateOnly)(file, yaml.print(request));
|
|
746
751
|
console.log(`Recorded Path-A replacement request for ${resolved}.`);
|
|
747
752
|
}
|
|
748
753
|
|
package/src/multiplexer.ts
CHANGED
|
@@ -164,13 +164,19 @@ async function targets(root: string, options: { strict?: string; unprojected?: U
|
|
|
164
164
|
if (!config.repository) continue;
|
|
165
165
|
const primary = resolve(config.repository);
|
|
166
166
|
result.push({ project: name, worktree: primary, workspaceLabel: name });
|
|
167
|
-
|
|
167
|
+
const seats = await projectSeats(root, name);
|
|
168
|
+
const mainOnPrimary = seats.some((entry) => entry.role === "main" && resolve(entry.worktree) === primary);
|
|
169
|
+
for (const entry of seats) {
|
|
168
170
|
try { assertNewTopologyPlacement(config, entry, primary); }
|
|
169
171
|
catch (error) {
|
|
170
172
|
if (entry.address === options.strict) throw error;
|
|
171
173
|
options.unprojected?.push({ address: entry.address, reason: (error as Error).message });
|
|
172
174
|
continue;
|
|
173
175
|
}
|
|
176
|
+
// main@project is the single primary coordinator projection: the preserved legacy generic
|
|
177
|
+
// coordinator@project shares its checkout and is not bulk-projected as a second tab. An explicit
|
|
178
|
+
// per-seat projection still projects it, and a project without a primary main keeps it.
|
|
179
|
+
if (mainOnPrimary && entry.role === "coordinator" && entry.address === `coordinator@${name}` && entry.address !== options.strict && resolve(entry.worktree) === primary) continue;
|
|
174
180
|
if ((entry.role === "main" || entry.role === "coordinator" || entry.role === "operator") && resolve(entry.worktree) === primary) {
|
|
175
181
|
result.push({ project: name, worktree: primary, workspaceLabel: name, seat: entry });
|
|
176
182
|
continue;
|
|
@@ -52,6 +52,22 @@ export async function sendTaskDirective(host: NoticeHost, root: string, seat: st
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The current task-derived action as text for a message the caller sends itself (a wake's single combined turn).
|
|
57
|
+
* Recording it as delivered keeps the settled-turn nudge from repeating the same directive.
|
|
58
|
+
*/
|
|
59
|
+
export async function deliveredDirectiveText(root: string, seat: string, state: NoticeState) {
|
|
60
|
+
try {
|
|
61
|
+
const directive = await nextDirective(root, seat);
|
|
62
|
+
if (!directive) return undefined;
|
|
63
|
+
const key = directiveKey(directive);
|
|
64
|
+
state.lastDelivered = key;
|
|
65
|
+
state.settledNudged = undefined;
|
|
66
|
+
if (directive.lifecycle === "BLOCKED") state.blockers.add(key);
|
|
67
|
+
return directiveNotice(seat, directive);
|
|
68
|
+
} catch { return undefined; }
|
|
69
|
+
}
|
|
70
|
+
|
|
55
71
|
/** A settled model turn is not task completion. Give one additional nudge for a
|
|
56
72
|
* still-actionable directive, but never loop on unchanged task state. */
|
|
57
73
|
export async function sendSettledDirective(host: NoticeHost, root: string, seat: string, state: NoticeState) {
|
package/src/operator-launch.ts
CHANGED
|
@@ -26,7 +26,7 @@ export async function operatorRelaunchSection(root: string, record: Seat) {
|
|
|
26
26
|
const locator = record.runtime?.addresses.herdr;
|
|
27
27
|
const session = (typeof locator === "object" ? locator.session : undefined) ?? (await desk(root)).herdr_session;
|
|
28
28
|
const command = session ? canonicalLaunchCommand(record.address, session) : "UNAVAILABLE: no Herdr session is recorded for this seat or Desk.";
|
|
29
|
-
return `\nCANONICAL RELAUNCH COMMAND (generated by Flow
|
|
29
|
+
return `\nCANONICAL RELAUNCH COMMAND (generated by Flow. Exit Pi in the operator pane so it is an idle shell, then run this from a DIFFERENT pane (e.g. another pane in this workspace), never inside the target pane; afterwards run \`atdd-flow handover resumed ${record.address}\`):\n${command}\n`;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
const notBefore = (value: string | undefined, floor: string | undefined) => Boolean(value && floor && Date.parse(value) >= Date.parse(floor));
|