@afokapu/atdd-flow 0.3.71 → 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 +24 -1
- package/extensions/pi/index.ts +124 -4
- package/package.json +1 -1
- package/src/cli.ts +15 -3
- package/src/core.ts +37 -0
- 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/tasks.ts +102 -2
- package/src/threads.ts +6 -2
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
|
|
|
@@ -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/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";
|
|
@@ -40,6 +41,7 @@ Usage:
|
|
|
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>
|
|
@@ -51,7 +53,9 @@ Usage:
|
|
|
51
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>]
|
|
@@ -122,6 +126,12 @@ async function main() {
|
|
|
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));
|
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
|
|
|
@@ -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));
|
package/src/pi-runtime-launch.ts
CHANGED
|
@@ -43,6 +43,10 @@ export type PiRuntimeLaunchDependencies = {
|
|
|
43
43
|
sleep?: (milliseconds: number) => Promise<void>;
|
|
44
44
|
/** How long Herdr may take to report the exact session path after start. */
|
|
45
45
|
sessionPathWindowMs?: number;
|
|
46
|
+
/** Herdr pane hosting this launcher process; defaults to HERDR_PANE_ID. Injectable for tests. */
|
|
47
|
+
hostPane?: string;
|
|
48
|
+
/** Pid of this launcher process; defaults to process.pid. Injectable for tests. */
|
|
49
|
+
hostPid?: number;
|
|
46
50
|
};
|
|
47
51
|
|
|
48
52
|
type Bind = (root: string, address: string, args: string[], selectedModel?: string, wake?: Runtime["wake"], piSession?: string, piSessionPath?: string, launchReceipt?: string) => Promise<void>;
|
|
@@ -154,7 +158,7 @@ export const piExtensionPath = () => join(import.meta.dir, "..", "extensions", "
|
|
|
154
158
|
type HerdrRequest = { session: string; pane: string; seat: string; root: string; piSession: string; sessionPath?: string; resume: boolean; args: string[] };
|
|
155
159
|
type Command = (command: string[]) => Promise<string>;
|
|
156
160
|
type PaneReport = { pane: string; agent?: string; status?: string; sessionPath?: string };
|
|
157
|
-
type ProcessReport = { shell?: number; processes: Array<{ name?: string; argv0?: string; cmdline?: string }> };
|
|
161
|
+
type ProcessReport = { shell?: number; processes: Array<{ pid?: number; name?: string; argv0?: string; cmdline?: string }> };
|
|
158
162
|
|
|
159
163
|
const object = (value: unknown): Record<string, unknown> | undefined => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
160
164
|
const text = (value: unknown) => typeof value === "string" ? value : undefined;
|
|
@@ -176,7 +180,7 @@ function processReport(value: unknown): ProcessReport {
|
|
|
176
180
|
const report = object(result(value)?.process_info) ?? result(value);
|
|
177
181
|
const processes = Array.isArray(report?.foreground_processes) ? report.foreground_processes.flatMap((entry) => {
|
|
178
182
|
const process = object(entry);
|
|
179
|
-
return process ? [{ name: text(process.name), argv0: text(process.argv0), cmdline: text(process.cmdline) }] : [];
|
|
183
|
+
return process ? [{ pid: number(process.pid), name: text(process.name), argv0: text(process.argv0), cmdline: text(process.cmdline) }] : [];
|
|
180
184
|
}) : [];
|
|
181
185
|
return { ...(number(report?.shell_pid) ? { shell: number(report?.shell_pid) } : {}), processes };
|
|
182
186
|
}
|
|
@@ -305,15 +309,22 @@ function assertProjectedPane(launch: PreparedLaunch, pane: string) {
|
|
|
305
309
|
if (replacement) assertReplacementTarget(replacement.request, options.herdrSession, pane, record.worktree);
|
|
306
310
|
}
|
|
307
311
|
|
|
312
|
+
const sameTerminalRefusal = "Run this from a different pane; the target pane must be an idle shell.";
|
|
313
|
+
|
|
308
314
|
/** Require both Herdr metadata and process inspection to report a vacant target. */
|
|
309
315
|
async function assertVacantTarget(launch: PreparedLaunch, pane: string) {
|
|
310
|
-
const { command, options } = launch;
|
|
316
|
+
const { command, options, dependencies } = launch;
|
|
317
|
+
const hostPane = dependencies.hostPane ?? process.env.HERDR_PANE_ID;
|
|
318
|
+
// The launcher typing into its own tty would corrupt the pane: refuse before any Herdr call.
|
|
319
|
+
if (hostPane === pane) throw new Error(sameTerminalRefusal);
|
|
311
320
|
const [paneRaw, processRaw] = await Promise.all([
|
|
312
321
|
command(herdr(options.herdrSession, "pane", "get", pane)),
|
|
313
322
|
command(herdr(options.herdrSession, "pane", "process-info", "--pane", pane)),
|
|
314
323
|
]);
|
|
315
324
|
const currentPane = paneReport(JSON.parse(paneRaw));
|
|
316
325
|
if (currentPane.pane !== pane) throw new Error("Herdr reported a different pane; refusing runtime launch.");
|
|
326
|
+
const hostPid = dependencies.hostPid ?? process.pid;
|
|
327
|
+
if (processReport(JSON.parse(processRaw)).processes.some((entry) => entry.pid === hostPid)) throw new Error(sameTerminalRefusal);
|
|
317
328
|
if (currentPane.agent || piProcess(processReport(JSON.parse(processRaw)).processes)) {
|
|
318
329
|
throw new Error(options.resume ? "Resume requires the prior Pi to have exited and been released." : "New launch requires an available shell pane.");
|
|
319
330
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// URN: component:flow:e004:pi-wake-protocol:backend:domain
|
|
2
|
+
// Tested-By:
|
|
3
|
+
// - test:atdd-flow:fleet-model-swap-and-wake
|
|
4
|
+
// Runtime: bun
|
|
5
|
+
// Purpose: Name shared /flow-wake transcript entries and read a transcript's active branch.
|
|
6
|
+
|
|
7
|
+
export const wakeCapabilityType = "flow-wake-capability/v1";
|
|
8
|
+
export const wakeRequestType = "flow-wake-request/v1";
|
|
9
|
+
export const wakeResultType = "flow-wake-result/v1";
|
|
10
|
+
export const wakeStartType = "atdd-flow-start";
|
|
11
|
+
/** Unique per attempt; also the only argument besides an optional portfolio candidate id the extension command accepts. */
|
|
12
|
+
export const wakeRequestIdPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{5,127}$/;
|
|
13
|
+
|
|
14
|
+
export type TranscriptEntry = {
|
|
15
|
+
type?: string;
|
|
16
|
+
id?: string;
|
|
17
|
+
parentId?: string | null;
|
|
18
|
+
timestamp?: string;
|
|
19
|
+
customType?: string;
|
|
20
|
+
data?: Record<string, unknown>;
|
|
21
|
+
details?: Record<string, unknown>;
|
|
22
|
+
provider?: string;
|
|
23
|
+
modelId?: string;
|
|
24
|
+
thinkingLevel?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** A line that is not complete JSON (a transcript being appended to) is ignored rather than trusted. */
|
|
28
|
+
export function parseTranscript(text: string): TranscriptEntry[] {
|
|
29
|
+
const entries: TranscriptEntry[] = [];
|
|
30
|
+
for (const line of text.split("\n")) {
|
|
31
|
+
if (!line.trim()) continue;
|
|
32
|
+
try {
|
|
33
|
+
const value = JSON.parse(line) as unknown;
|
|
34
|
+
if (value && typeof value === "object" && !Array.isArray(value)) entries.push(value as TranscriptEntry);
|
|
35
|
+
} catch { /* A partial trailing line is never evidence. */ }
|
|
36
|
+
}
|
|
37
|
+
return entries;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The chain from the most recently appended entry back through its parents, oldest first; abandoned siblings are excluded. */
|
|
41
|
+
export function activeBranch(entries: TranscriptEntry[]): TranscriptEntry[] {
|
|
42
|
+
const nodes = entries.filter((entry) => entry.type !== "session" && typeof entry.id === "string");
|
|
43
|
+
const byId = new Map(nodes.map((entry) => [entry.id!, entry]));
|
|
44
|
+
const chain: TranscriptEntry[] = [];
|
|
45
|
+
const seen = new Set<string>();
|
|
46
|
+
let current = nodes.at(-1);
|
|
47
|
+
while (current && !seen.has(current.id!)) {
|
|
48
|
+
seen.add(current.id!);
|
|
49
|
+
chain.push(current);
|
|
50
|
+
current = typeof current.parentId === "string" ? byId.get(current.parentId) : undefined;
|
|
51
|
+
}
|
|
52
|
+
return chain.reverse();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const customEntries = (branch: TranscriptEntry[], customType: string) =>
|
|
56
|
+
branch.filter((entry) => (entry.type === "custom" || entry.type === "custom_message") && entry.customType === customType);
|
|
57
|
+
|
|
58
|
+
/** The latest model selection on the branch, as `provider/model`. */
|
|
59
|
+
export function branchModel(branch: TranscriptEntry[]) {
|
|
60
|
+
const change = branch.filter((entry) => entry.type === "model_change" && entry.provider && entry.modelId).at(-1);
|
|
61
|
+
return change ? { provider: change.provider!, model: change.modelId! } : undefined;
|
|
62
|
+
}
|
package/src/pi-wake.ts
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// URN: component:flow:e004:pi-wake:backend:application
|
|
2
|
+
// Tested-By:
|
|
3
|
+
// - test:atdd-flow:fleet-model-swap-and-wake
|
|
4
|
+
// Runtime: bun
|
|
5
|
+
// Purpose: Wake live Herdr-bound Pi seats with /flow-wake, optionally swapping model.
|
|
6
|
+
|
|
7
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { addressParts, canonicalAddress, modelPortfolio, paths, piModel, readYaml, type ModelPortfolio, type Seat } from "./core";
|
|
10
|
+
import { readRuntimeState } from "./runtime-state";
|
|
11
|
+
import { seatTasks, terminal } from "./tasks";
|
|
12
|
+
import {
|
|
13
|
+
type TranscriptEntry, activeBranch, branchModel, customEntries, parseTranscript,
|
|
14
|
+
wakeCapabilityType, wakeRequestType, wakeResultType, wakeStartType,
|
|
15
|
+
} from "./pi-wake-protocol";
|
|
16
|
+
|
|
17
|
+
export const wakeUsage = "Use `atdd-flow pi wake <seat>|--scope project|desk [--model-profile <name>] --by <authorized-seat>`.";
|
|
18
|
+
|
|
19
|
+
type Outcome = "woke" | "swapped+woke" | "deferred" | "refused" | "skipped" | "failed";
|
|
20
|
+
export type WakeRow = { seat: string; outcome: Outcome; detail?: string };
|
|
21
|
+
type Options = { target?: string; scope?: "project" | "desk"; profile?: string; by: string };
|
|
22
|
+
type Target = { provider: string; model: string };
|
|
23
|
+
/** What Herdr reports about the bound pane; nothing here is trusted until it matches the Desk and runtime records. */
|
|
24
|
+
type Pane = { agent?: string; status?: string; sessionPath?: string; pids: number[] };
|
|
25
|
+
type Ready = { session: string; pane: string; sessionId: string; sessionPath: string };
|
|
26
|
+
type Inspection = { row: WakeRow } | { ready: Ready };
|
|
27
|
+
type Prepared = { row: WakeRow } | { record: Seat; ready: Ready; inspectedAt: number };
|
|
28
|
+
|
|
29
|
+
const pollMs = 50;
|
|
30
|
+
/** A runtime heartbeat older than this is not a live Pi (the extension beats every 15 s). */
|
|
31
|
+
const heartbeatMs = 120_000;
|
|
32
|
+
/** An inspection this recent still describes the seat; an older one is repeated just before anything is sent. */
|
|
33
|
+
const freshMs = 2_000;
|
|
34
|
+
|
|
35
|
+
/** Every wait is bounded; the variable exists so fixtures that never answer fail quickly. */
|
|
36
|
+
const timeouts = () => {
|
|
37
|
+
const configured = Number(process.env.ATDD_FLOW_WAKE_TIMEOUT_MS);
|
|
38
|
+
const explicit = Number.isFinite(configured) && configured > 0 ? configured : undefined;
|
|
39
|
+
// A bootstrap reload may start a full resumed-work turn; waiting for the seat to be idle again is bounded separately.
|
|
40
|
+
return { step: explicit ?? 30_000, idle: explicit ?? 300_000 };
|
|
41
|
+
};
|
|
42
|
+
const pause = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
43
|
+
const row = (seat: string, outcome: Outcome, detail?: string): WakeRow => ({ seat, outcome, ...(detail ? { detail } : {}) });
|
|
44
|
+
const hold = (seat: string, outcome: Outcome, detail: string): { row: WakeRow } => ({ row: row(seat, outcome, detail) });
|
|
45
|
+
const object = (value: unknown): Record<string, unknown> | undefined => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
46
|
+
const text = (value: unknown) => typeof value === "string" ? value : undefined;
|
|
47
|
+
const message = (error: unknown) => error instanceof Error ? error.message : String(error);
|
|
48
|
+
|
|
49
|
+
/** Polls until `read` yields a value or the bound elapses; one read always happens. */
|
|
50
|
+
async function until<T>(read: () => Promise<T | undefined>, timeoutMs: number) {
|
|
51
|
+
const deadline = Date.now() + timeoutMs;
|
|
52
|
+
for (;;) {
|
|
53
|
+
const value = await read();
|
|
54
|
+
if (value !== undefined) return value;
|
|
55
|
+
if (Date.now() >= deadline) return undefined;
|
|
56
|
+
await pause(pollMs);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function flagsOf(args: string[]) {
|
|
61
|
+
const flags = new Map<string, string>(), targets: string[] = [];
|
|
62
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
63
|
+
const argument = args[index]!, value = args[index + 1];
|
|
64
|
+
if (!argument.startsWith("--")) targets.push(argument);
|
|
65
|
+
else if (["--scope", "--model-profile", "--by"].includes(argument) && value && !value.startsWith("--") && !flags.has(argument)) { flags.set(argument, value); index += 1; }
|
|
66
|
+
else throw new Error(wakeUsage);
|
|
67
|
+
}
|
|
68
|
+
return { flags, targets };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The grammar is closed: exactly one of a seat or --scope, one --by, and an optional profile. */
|
|
72
|
+
export function parseWakeArguments(args: string[]): Options {
|
|
73
|
+
const { flags, targets } = flagsOf(args);
|
|
74
|
+
const scope = flags.get("--scope"), by = flags.get("--by"), profile = flags.get("--model-profile");
|
|
75
|
+
if (!by || targets.length + (scope ? 1 : 0) !== 1) throw new Error(wakeUsage);
|
|
76
|
+
if (scope && scope !== "project" && scope !== "desk") throw new Error(wakeUsage);
|
|
77
|
+
return { ...(targets[0] ? { target: targets[0] } : {}), ...(scope ? { scope } : {}), ...(profile ? { profile } : {}), by };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function herdr(session: string, ...args: string[]) {
|
|
81
|
+
const child = Bun.spawn(["herdr", "--session", session, ...args], { stdout: "pipe", stderr: "pipe" });
|
|
82
|
+
const [stdout, stderr, code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
|
|
83
|
+
if (code !== 0) throw new Error(`herdr ${args.slice(0, 2).join(" ")} failed: ${stderr.trim() || stdout.trim()}`);
|
|
84
|
+
return stdout;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Herdr pane metadata plus foreground processes; read-only. */
|
|
88
|
+
async function observe(session: string, pane: string): Promise<Pane> {
|
|
89
|
+
const [paneRaw, processRaw] = await Promise.all([herdr(session, "pane", "get", pane), herdr(session, "pane", "process-info", "--pane", pane)]);
|
|
90
|
+
const info = object(object(object(JSON.parse(paneRaw))?.result)?.pane);
|
|
91
|
+
const processResult = object(object(JSON.parse(processRaw))?.result);
|
|
92
|
+
const processes = object(processResult?.process_info)?.foreground_processes;
|
|
93
|
+
const sessionInfo = object(info?.agent_session);
|
|
94
|
+
return {
|
|
95
|
+
...(text(info?.agent) ? { agent: text(info?.agent) } : {}),
|
|
96
|
+
...(text(info?.agent_status) ? { status: text(info?.agent_status) } : {}),
|
|
97
|
+
...(sessionInfo?.kind === "path" && text(sessionInfo.value) ? { sessionPath: text(sessionInfo.value) } : {}),
|
|
98
|
+
pids: (Array.isArray(processes) ? processes : []).flatMap((entry) => typeof object(entry)?.pid === "number" ? [object(entry)!.pid as number] : []),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The transcript's active branch: the chain from the newest entry, excluding abandoned siblings. */
|
|
103
|
+
const readBranch = async (path: string) => activeBranch(parseTranscript(await readFile(path, "utf8")));
|
|
104
|
+
|
|
105
|
+
/** Resolve a profile mapping for the seat's current model. `{}` means wake without a swap. */
|
|
106
|
+
function resolveSwap(portfolio: ModelPortfolio, profile: string, branch: TranscriptEntry[]): { candidate?: string; target?: Target } | { refused: string } {
|
|
107
|
+
const current = branchModel(branch);
|
|
108
|
+
if (!current) return { refused: "no model recorded in the transcript" };
|
|
109
|
+
const migration = portfolio.model_migrations?.[profile] ?? {};
|
|
110
|
+
const exact = (id: string) => { const candidate = portfolio.models.find((entry) => entry.id === id); return candidate && piModel(candidate); };
|
|
111
|
+
const same = (target?: Target) => target?.provider === current.provider && target.model === current.model;
|
|
112
|
+
const id = migration[`${current.provider}/${current.model}`];
|
|
113
|
+
// A seat already on a mapped target needs no swap, which makes a partly applied fleet run safe to repeat.
|
|
114
|
+
if (!id) return Object.values(migration).some((target) => same(exact(target))) ? {} : { refused: `no ${profile} migration for ${current.provider}/${current.model}` };
|
|
115
|
+
const target = exact(id);
|
|
116
|
+
return !target || same(target) ? {} : { candidate: id, target };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function bindingOf(record: Seat) {
|
|
120
|
+
const locator = record.runtime?.addresses.herdr;
|
|
121
|
+
return record.runtime?.application === "herdr" && typeof locator === "object" && locator.session && locator.pane ? locator : undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Why the live pane cannot be trusted as this seat's Pi, or undefined when it can. */
|
|
125
|
+
async function identityProblem(root: string, address: string, record: Seat, live: Pane) {
|
|
126
|
+
const runtime = await readRuntimeState(root, address);
|
|
127
|
+
if (!runtime || Date.now() - Date.parse(runtime.heartbeat_at) > heartbeatMs) return "no live Pi runtime heartbeat";
|
|
128
|
+
const recorded = record.runtime;
|
|
129
|
+
if (!recorded?.pi_session || !recorded.pi_session_path) return "seat records no exact Pi session";
|
|
130
|
+
if (live.sessionPath !== recorded.pi_session_path) return "Herdr reports a different Pi session than the seat binding";
|
|
131
|
+
return live.pids.includes(runtime.pid) ? undefined : "pane foreground process is not the registered Pi runtime";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Only idle/done is a safe boundary: working defers, any blocking UI or unknown state is refused. */
|
|
135
|
+
function boundary(address: string, status?: string) {
|
|
136
|
+
if (status === "idle" || status === "done") return undefined;
|
|
137
|
+
return status === "working" ? row(address, "deferred", "agent is working") : row(address, "refused", `agent is ${status ?? "in an unknown state"}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function absentAgent(live: Pane) {
|
|
141
|
+
if (!live.agent) return "no agent in the bound pane";
|
|
142
|
+
return live.agent === "pi" ? undefined : `bound pane runs ${live.agent}, not Pi`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Read-only check that the seat's recorded pane, session and process are the live Pi at a safe boundary. */
|
|
146
|
+
async function inspect(root: string, address: string, record: Seat): Promise<Inspection> {
|
|
147
|
+
const locator = bindingOf(record);
|
|
148
|
+
if (!locator) return hold(address, "skipped", "no native Herdr binding");
|
|
149
|
+
const live = await observe(locator.session, locator.pane).catch((error) => error as Error);
|
|
150
|
+
if (live instanceof Error) return hold(address, "refused", `Herdr observation failed: ${live.message}`);
|
|
151
|
+
const absent = absentAgent(live);
|
|
152
|
+
if (absent) return hold(address, "skipped", absent);
|
|
153
|
+
const problem = await identityProblem(root, address, record, live);
|
|
154
|
+
if (problem) return hold(address, "refused", problem);
|
|
155
|
+
const unsafe = boundary(address, live.status);
|
|
156
|
+
if (unsafe) return { row: unsafe };
|
|
157
|
+
return { ready: { session: locator.session, pane: locator.pane, sessionId: record.runtime!.pi_session!, sessionPath: record.runtime!.pi_session_path! } };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const capabilityOf = (branch: TranscriptEntry[], ready: Ready, address: string) =>
|
|
161
|
+
customEntries(branch, wakeCapabilityType).filter((entry) => entry.data?.seat === address && entry.data.pi_session === ready.sessionId && entry.data.pane === ready.pane);
|
|
162
|
+
|
|
163
|
+
/** Classify one seat without sending anything; every non-eligible seat resolves to its final row. */
|
|
164
|
+
async function prepare(root: string, address: string, actor: string): Promise<Prepared> {
|
|
165
|
+
if (address === actor) return hold(address, "skipped", "invoking seat");
|
|
166
|
+
const record = await readYaml<Seat>(paths(root).seatFile(address)).catch(() => undefined);
|
|
167
|
+
if (!record) return hold(address, "skipped", "no seat record");
|
|
168
|
+
if (record.retired) return hold(address, "skipped", "retired");
|
|
169
|
+
const assigned = await seatTasks(root, record.project, address).catch(() => []);
|
|
170
|
+
if (assigned.length && assigned.every((entry) => terminal(entry.task))) return hold(address, "skipped", "all assigned tasks are terminal");
|
|
171
|
+
const inspection = await inspect(root, address, record);
|
|
172
|
+
return "row" in inspection ? inspection : { record, ready: inspection.ready, inspectedAt: Date.now() };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A Pi that predates `/flow-wake` gets one `/reload` at a safe boundary; only a fresh capability entry proves it
|
|
177
|
+
* loaded, and the seat must return to idle/done (the reload may start a normal startup turn) before the wake.
|
|
178
|
+
*/
|
|
179
|
+
async function bootstrap(root: string, address: string, record: Seat, ready: Ready, branch: TranscriptEntry[]): Promise<WakeRow | undefined> {
|
|
180
|
+
const limit = timeouts(), known = new Set(customEntries(branch, wakeCapabilityType).map((entry) => entry.id));
|
|
181
|
+
try { await herdr(ready.session, "agent", "prompt", ready.pane, "/reload"); }
|
|
182
|
+
catch (error) { return row(address, "failed", message(error)); }
|
|
183
|
+
const fresh = await until(async () => capabilityOf(await readBranch(ready.sessionPath), ready, address).some((entry) => !known.has(entry.id)) || undefined, limit.step);
|
|
184
|
+
if (!fresh) return row(address, "failed", "no fresh flow-wake capability after /reload; the Flow extension did not activate");
|
|
185
|
+
const settled = await until(async () => {
|
|
186
|
+
const again = await inspect(root, address, record);
|
|
187
|
+
return "row" in again && again.row.outcome === "deferred" ? undefined : again;
|
|
188
|
+
}, limit.idle);
|
|
189
|
+
if (!settled) return row(address, "failed", "seat did not return to idle/done after reload");
|
|
190
|
+
return "row" in settled ? settled.row : undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Dispatch one wake to an eligible seat and verify it from the seat's own transcript. */
|
|
194
|
+
async function wakeOne(root: string, address: string, prepared: Extract<Prepared, { record: Seat }>, portfolio: ModelPortfolio | undefined, profile: string | undefined): Promise<WakeRow> {
|
|
195
|
+
const { record } = prepared;
|
|
196
|
+
const inspection = Date.now() - prepared.inspectedAt <= freshMs ? { ready: prepared.ready } : await inspect(root, address, record);
|
|
197
|
+
if ("row" in inspection) return inspection.row;
|
|
198
|
+
const { ready } = inspection;
|
|
199
|
+
const branch = await readBranch(ready.sessionPath).catch((error) => error as Error);
|
|
200
|
+
if (branch instanceof Error) return row(address, "refused", `cannot read the Pi transcript: ${branch.message}`);
|
|
201
|
+
const swap = profile ? resolveSwap(portfolio!, profile, branch) : {};
|
|
202
|
+
if ("refused" in swap) return row(address, "refused", swap.refused);
|
|
203
|
+
const unready = capabilityOf(branch, ready, address).length ? undefined : await bootstrap(root, address, record, ready, branch);
|
|
204
|
+
if (unready) return unready;
|
|
205
|
+
|
|
206
|
+
const requestId = `wake-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
|
|
207
|
+
try { await herdr(ready.session, "agent", "prompt", ready.pane, swap.candidate ? `/flow-wake ${swap.candidate} ${requestId}` : `/flow-wake ${requestId}`); }
|
|
208
|
+
catch (error) { return row(address, "failed", message(error)); }
|
|
209
|
+
return verify(ready.sessionPath, address, requestId, swap.target, timeouts().step);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
type Verdict = { outcome: "failed"; detail: string } | { outcome: "woke" | "swapped+woke" };
|
|
213
|
+
|
|
214
|
+
/** A swap needs a native model change to exactly the target between request and result, and a result that says so. */
|
|
215
|
+
function modelProblem(span: TranscriptEntry[], result: Record<string, unknown>, target: Target | undefined) {
|
|
216
|
+
const changed = span.filter((entry) => entry.type === "model_change").at(-1);
|
|
217
|
+
if (!target) return changed ? "unexpected model change during a keep-model wake" : undefined;
|
|
218
|
+
if (changed?.provider !== target.provider || changed.modelId !== target.model) return "no native model change to the requested target between request and result";
|
|
219
|
+
const reported = object(result.target);
|
|
220
|
+
return reported?.provider === target.provider && reported.model === target.model ? undefined : "result does not report the requested target";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Undefined until request, result and the one startup turn for this request id are all present in order. */
|
|
224
|
+
function judge(branch: TranscriptEntry[], requestId: string, target: Target | undefined): Verdict | undefined {
|
|
225
|
+
const requestAt = branch.findIndex((entry) => entry.customType === wakeRequestType && entry.data?.request_id === requestId);
|
|
226
|
+
const resultAt = branch.findIndex((entry, index) => index > requestAt && entry.customType === wakeResultType && entry.data?.request_id === requestId);
|
|
227
|
+
if (requestAt < 0 || resultAt < 0) return undefined;
|
|
228
|
+
const result = branch[resultAt]!.data!, expected = target ? "swapped+woke" : "woke";
|
|
229
|
+
if (result.outcome === "failed") return { outcome: "failed", detail: text(result.reason) ?? "the seat reported a failed wake" };
|
|
230
|
+
const problem = result.outcome === expected ? modelProblem(branch.slice(requestAt, resultAt), result, target) : `unexpected result outcome ${String(result.outcome)}`;
|
|
231
|
+
if (problem) return { outcome: "failed", detail: problem };
|
|
232
|
+
const started = branch.slice(resultAt).some((entry) => entry.customType === wakeStartType && entry.details?.request_id === requestId);
|
|
233
|
+
return started ? { outcome: expected } : undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Waits a bounded time for the complete handshake; whatever is missing is reported, never retried. */
|
|
237
|
+
async function verify(sessionPath: string, address: string, requestId: string, target: Target | undefined, timeoutMs: number): Promise<WakeRow> {
|
|
238
|
+
const verdict = await until(async () => judge(await readBranch(sessionPath).catch(() => []), requestId, target), timeoutMs);
|
|
239
|
+
if (verdict) return verdict.outcome === "failed" ? row(address, "failed", verdict.detail) : row(address, verdict.outcome);
|
|
240
|
+
const branch = await readBranch(sessionPath).catch(() => []);
|
|
241
|
+
const seen = [wakeRequestType, wakeResultType].filter((type) => branch.some((entry) => entry.customType === type && entry.data?.request_id === requestId));
|
|
242
|
+
return row(address, "failed", `incomplete wake ${requestId}: observed ${seen.length ? seen.join(", ") : "nothing"}; not retried`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function seatAddresses(root: string, project: string) {
|
|
246
|
+
const entries = await readdir(paths(root).seats(project), { withFileTypes: true }).catch(() => []);
|
|
247
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => `${entry.name}@${project}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Authorization is by address: the operator may wake anything; a project main only its own project. */
|
|
251
|
+
async function targetAddresses(root: string, options: Options, actor: string) {
|
|
252
|
+
const operator = actor === "operator@desk";
|
|
253
|
+
if (options.target) {
|
|
254
|
+
const target = await canonicalAddress(root, options.target), project = addressParts(target).project;
|
|
255
|
+
if (!operator && actor !== `main@${project}`) throw new Error(`${actor} is not authorized to wake ${target}; use operator@desk or main@${project}.`);
|
|
256
|
+
return [target];
|
|
257
|
+
}
|
|
258
|
+
if (options.scope === "desk") {
|
|
259
|
+
if (!operator) throw new Error("Desk scope requires --by operator@desk.");
|
|
260
|
+
const projects = await readdir(paths(root).work, { withFileTypes: true }).catch(() => []);
|
|
261
|
+
return (await Promise.all(projects.filter((entry) => entry.isDirectory()).map((entry) => seatAddresses(root, entry.name)))).flat();
|
|
262
|
+
}
|
|
263
|
+
const { local, project } = addressParts(actor);
|
|
264
|
+
if (local !== "main") throw new Error("Project scope requires --by main@<project>.");
|
|
265
|
+
return seatAddresses(root, project);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Runs the closed `pi wake` grammar; reports each seat's row in lexical order as it completes. */
|
|
269
|
+
export async function wakePi(root: string, args: string[], report: (line: WakeRow) => void = () => undefined) {
|
|
270
|
+
const options = parseWakeArguments(args);
|
|
271
|
+
const actor = await canonicalAddress(root, options.by);
|
|
272
|
+
const addresses = (await targetAddresses(root, options, actor)).sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
273
|
+
const portfolio = options.profile ? await modelPortfolio(root) : undefined;
|
|
274
|
+
if (options.profile && !portfolio?.model_migrations?.[options.profile]) throw new Error(`models.yaml declares no model_migrations profile ${options.profile}.`);
|
|
275
|
+
|
|
276
|
+
// Classification is read-only and concurrent; anything that sends a prompt then runs one seat at a time in lexical order.
|
|
277
|
+
const prepared = await Promise.all(addresses.map((address) => prepare(root, address, actor).catch((error) => hold(address, "failed", message(error)) as Prepared)));
|
|
278
|
+
const rows: WakeRow[] = [];
|
|
279
|
+
for (const [index, address] of addresses.entries()) {
|
|
280
|
+
const ready = prepared[index]!;
|
|
281
|
+
const outcome = "row" in ready ? ready.row : await wakeOne(root, address, ready, portfolio, options.profile).catch((error) => row(address, "failed", message(error)));
|
|
282
|
+
rows.push(outcome);
|
|
283
|
+
report(outcome);
|
|
284
|
+
}
|
|
285
|
+
// A named seat must be woken; a fleet is unsuccessful only when something was refused or failed.
|
|
286
|
+
const woke = (entry: WakeRow) => entry.outcome === "woke" || entry.outcome === "swapped+woke";
|
|
287
|
+
const success = options.target ? rows.every(woke) : rows.every((entry) => entry.outcome !== "refused" && entry.outcome !== "failed");
|
|
288
|
+
return { rows, success };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export const formatWakeRow = (entry: WakeRow) => `${entry.seat} ${entry.outcome}${entry.detail ? ` ${entry.detail}` : ""}`;
|
package/src/tasks.ts
CHANGED
|
@@ -29,6 +29,19 @@ export type CoordinatorTransfer = {
|
|
|
29
29
|
};
|
|
30
30
|
export type TerminalDisposition = { kind: "superseded" | "rejected"; by: string; at: string; reason: string };
|
|
31
31
|
export type DeferredDisposition = { by: string; at: string; reason: string; owner: string; trigger: string; review_at: string };
|
|
32
|
+
export type DispatchAlert = { key: string; message: string; observed_at: string };
|
|
33
|
+
/** Persisted evidence for an immediate assignment; each field is written only by its own typed command. */
|
|
34
|
+
export type ImmediateDispatch = {
|
|
35
|
+
thread: string;
|
|
36
|
+
message: string;
|
|
37
|
+
priority: string;
|
|
38
|
+
mutation_boundary: string;
|
|
39
|
+
assignee: string;
|
|
40
|
+
status: "dispatched";
|
|
41
|
+
acknowledgement?: { dispatch: string; by: string; acknowledged_at: string };
|
|
42
|
+
start?: { by: string; transitioned_at: string };
|
|
43
|
+
alerts?: { missing_acknowledgement?: DispatchAlert; missing_start?: DispatchAlert };
|
|
44
|
+
};
|
|
32
45
|
export type Task = {
|
|
33
46
|
schema: "atdd-workflow/task/v1";
|
|
34
47
|
title: string;
|
|
@@ -51,6 +64,8 @@ export type Task = {
|
|
|
51
64
|
disposition?: TerminalDisposition;
|
|
52
65
|
/** Bounded hold with an accountable owner, trigger, and review point. */
|
|
53
66
|
deferred?: DeferredDisposition;
|
|
67
|
+
/** Absent unless assigned with `--immediate`. */
|
|
68
|
+
immediate_dispatch?: ImmediateDispatch;
|
|
54
69
|
done_when: DoneWhen[];
|
|
55
70
|
};
|
|
56
71
|
|
|
@@ -346,11 +361,95 @@ export async function assign(root: string, projectName: string, id: string, args
|
|
|
346
361
|
if (task.status !== "todo") throw new Error(`Task ${id} can only be assigned while todo.`);
|
|
347
362
|
if (task.assignee) throw new Error(`Task ${id} is already assigned to ${task.assignee}.`);
|
|
348
363
|
const assignee = await canonicalAddress(root, required(words(args, "--assignee"), "--assignee"));
|
|
349
|
-
const
|
|
364
|
+
const target = await eligibleAssignee(root, projectName, assignee);
|
|
365
|
+
const base = await evidenceBase(root, projectName, task.coordinator, target);
|
|
366
|
+
const immediate = args.includes("--immediate") ? await dispatchImmediately(root, projectName, id, task, target, args) : undefined;
|
|
350
367
|
task.assignee = assignee;
|
|
351
368
|
if (base) task.governed_base = base;
|
|
369
|
+
if (immediate) task.immediate_dispatch = immediate;
|
|
370
|
+
await writeTask(root, projectName, id, task);
|
|
371
|
+
console.log(`${id} assigned ${assignee}${immediate ? " dispatched" : ""}`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** The assignee's todo -> in_progress transition is the only writer of start evidence. */
|
|
375
|
+
function recordDispatchStart(task: Task, next: TaskStatus, actor: string) {
|
|
376
|
+
if (next === "in_progress" && task.status === "todo" && task.immediate_dispatch) {
|
|
377
|
+
task.immediate_dispatch.start = { by: actor, transitioned_at: now() };
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function quietly<T>(run: () => Promise<T>) {
|
|
382
|
+
const log = console.log;
|
|
383
|
+
console.log = () => {};
|
|
384
|
+
try { return await run(); }
|
|
385
|
+
finally { console.log = log; }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Fails closed before any write when the assignee has no native queue, then persists one task-linked
|
|
390
|
+
* dispatch through the existing native mail path. The task write that claims "dispatched" happens last.
|
|
391
|
+
*/
|
|
392
|
+
async function dispatchImmediately(root: string, projectName: string, id: string, task: Task, target: Seat, args: string[]): Promise<ImmediateDispatch> {
|
|
393
|
+
const priority = required(words(args, "--priority"), "--priority");
|
|
394
|
+
const boundary = required(words(args, "--mutation-boundary"), "--mutation-boundary");
|
|
395
|
+
const threads = await import("./threads");
|
|
396
|
+
if (!threads.hasNativeQueue(target)) throw new Error(`Assignee ${target.address} has no native queue; immediate assignment needs a native Flow inbox.`);
|
|
397
|
+
const thread = await quietly(() => threads.startThread(root, [
|
|
398
|
+
"--with", `${task.coordinator},${target.address}`, "--subject", `Immediate assignment ${projectName}/${id}`, "--task", `${projectName}/${id}`,
|
|
399
|
+
]));
|
|
400
|
+
const body = [
|
|
401
|
+
`Immediate assignment: ${projectName}/${id}`, `Priority: ${priority}`, `Mutation boundary: ${boundary}`,
|
|
402
|
+
`Acknowledge (typed, not prose): atdd-flow task acknowledge ${projectName} ${id} --dispatch <this message id> --by ${target.address}`,
|
|
403
|
+
`Start: atdd-flow task start ${projectName} ${id} --by ${target.address}`,
|
|
404
|
+
].join("\n");
|
|
405
|
+
const message = await quietly(() => threads.post(root, thread, ["--from", task.coordinator, "--to", target.address, "--label", "dispatch", "--expects-result", "--body", body]));
|
|
406
|
+
return { thread, message, priority, mutation_boundary: boundary, assignee: target.address, status: "dispatched" };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Binds an acknowledgement to the exact stored dispatch and assignee; it never changes task status. */
|
|
410
|
+
export async function acknowledge(root: string, projectName: string, id: string, args: string[]) {
|
|
411
|
+
const task = await readTask(root, projectName, taskId(id));
|
|
412
|
+
const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
|
|
413
|
+
const dispatch = required(words(args, "--dispatch"), "--dispatch");
|
|
414
|
+
const stored = task.immediate_dispatch;
|
|
415
|
+
if (!stored) throw new Error(`Task ${id} has no immediate dispatch.`);
|
|
416
|
+
if (dispatch !== stored.message) throw new Error(`${dispatch} is not the dispatch for task ${id}.`);
|
|
417
|
+
if (actor !== stored.assignee || actor !== task.assignee) throw new Error(`Only ${stored.assignee} may acknowledge task ${id}.`);
|
|
418
|
+
if (terminal(task)) throw new Error(`Task ${id} is already ${task.status}.`);
|
|
419
|
+
if (!stored.acknowledgement) {
|
|
420
|
+
stored.acknowledgement = { dispatch, by: actor, acknowledged_at: now() };
|
|
421
|
+
await writeTask(root, projectName, id, task);
|
|
422
|
+
}
|
|
423
|
+
console.log(`${id} acknowledged ${actor}`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function missingSignal(task: Task): "missing_acknowledgement" | "missing_start" | undefined {
|
|
427
|
+
const stored = task.immediate_dispatch;
|
|
428
|
+
if (!stored || terminal(task)) return undefined;
|
|
429
|
+
if (!stored.acknowledgement) return "missing_acknowledgement";
|
|
430
|
+
return task.status === "todo" ? "missing_start" : undefined;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** On demand and bounded: posts at most one alert per missing state, then returns the stored one. */
|
|
434
|
+
export async function observeAssignment(root: string, projectName: string, id: string, args: string[]) {
|
|
435
|
+
const task = await readTask(root, projectName, taskId(id));
|
|
436
|
+
const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
|
|
437
|
+
if (actor !== task.coordinator) throw new Error(`Only ${task.coordinator} may observe the assignment of task ${id}.`);
|
|
438
|
+
const stored = task.immediate_dispatch;
|
|
439
|
+
if (!stored) throw new Error(`Task ${id} has no immediate dispatch.`);
|
|
440
|
+
const state = missingSignal(task);
|
|
441
|
+
if (!state) { console.log(`${id} no missing signal`); return; }
|
|
442
|
+
const existing = stored.alerts?.[state];
|
|
443
|
+
if (existing) { console.log(`${id} ${state} ${existing.message}`); return; }
|
|
444
|
+
const action = state === "missing_acknowledgement"
|
|
445
|
+
? `atdd-flow task acknowledge ${projectName} ${id} --dispatch ${stored.message} --by ${stored.assignee}`
|
|
446
|
+
: `atdd-flow task start ${projectName} ${id} --by ${stored.assignee}`;
|
|
447
|
+
const { post } = await import("./threads");
|
|
448
|
+
const message = await quietly(() => post(root, stored.thread, ["--from", task.coordinator, "--to", stored.assignee, "--label", state, "--expects-result", "--body",
|
|
449
|
+
`Alert ${state} for ${projectName}/${id} (dispatch ${stored.message}). Run: ${action}`]));
|
|
450
|
+
stored.alerts = { ...stored.alerts, [state]: { key: `${stored.message}/${state}`, message, observed_at: now() } };
|
|
352
451
|
await writeTask(root, projectName, id, task);
|
|
353
|
-
console.log(`${id}
|
|
452
|
+
console.log(`${id} ${state} ${message}`);
|
|
354
453
|
}
|
|
355
454
|
|
|
356
455
|
type TransferAuthorization = {
|
|
@@ -512,6 +611,7 @@ async function transition(root: string, projectName: string, id: string, next: T
|
|
|
512
611
|
await assertAcceptedBehavioralReview(root, projectName, id, task);
|
|
513
612
|
if (retire) await retireAssignee(root, projectName, id, task);
|
|
514
613
|
}
|
|
614
|
+
recordDispatchStart(task, next, actor);
|
|
515
615
|
task.status = next;
|
|
516
616
|
if (next !== "in_progress") { delete task.blocker; delete task.deferred; }
|
|
517
617
|
await writeTask(root, projectName, id, task);
|
package/src/threads.ts
CHANGED
|
@@ -234,9 +234,13 @@ export async function publishNativeMail(root: string, address: string, _segment:
|
|
|
234
234
|
});
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
/** The single predicate for a durable native inbox queue: an explicit native wake or a Pi-designated seat. */
|
|
238
|
+
export const hasNativeQueue = (target: { agent?: string; runtime?: { wake?: string } }) =>
|
|
239
|
+
target.runtime?.wake === "native" || target.agent === "pi";
|
|
240
|
+
|
|
237
241
|
async function prepareNativeMail(root: string, address: string, message: Message, threadId: string) {
|
|
238
242
|
const target = await seat(root, address);
|
|
239
|
-
if (target
|
|
243
|
+
if (!hasNativeQueue(target)) return;
|
|
240
244
|
const segment = await enqueueNativeMail(root, address, { thread: threadId, message: message.id, created_at: message.created_at });
|
|
241
245
|
return { address, segment };
|
|
242
246
|
}
|
|
@@ -244,7 +248,7 @@ async function prepareNativeMail(root: string, address: string, message: Message
|
|
|
244
248
|
async function inject(root: string, address: string, message: Message, threadId: string, queued = false) {
|
|
245
249
|
const target = await seat(root, address);
|
|
246
250
|
const runtime = target.runtime;
|
|
247
|
-
if (
|
|
251
|
+
if (hasNativeQueue(target)) {
|
|
248
252
|
if (!queued) await enqueueNativeMail(root, address, { thread: threadId, message: message.id, created_at: message.created_at, published: true });
|
|
249
253
|
if (!runtime || runtime.wake === "native") return;
|
|
250
254
|
}
|