omp-conductor 0.19.6 → 0.19.7
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/REFERENCE.md +17 -1
- package/package.json +1 -1
- package/src/daemon.ts +123 -32
- package/src/doctor.ts +17 -12
- package/src/escalate.ts +39 -21
- package/src/fleet.ts +928 -140
- package/src/store.ts +42 -0
- package/src/types.ts +19 -0
- package/src/verbs/server.ts +92 -11
package/REFERENCE.md
CHANGED
|
@@ -848,6 +848,22 @@ the queue label is refused with `file-lane-unparseable` and an actionable
|
|
|
848
848
|
message, because promoting beside overlapping work on a section that plainly
|
|
849
849
|
tried to declare is the exact gap the interlock exists to close.
|
|
850
850
|
|
|
851
|
+
Since #1036, a promotion also checks a contract. When the issue carries a
|
|
852
|
+
durable `promotable` grooming verdict (the store's grooming row, written by
|
|
853
|
+
the to-spec batch), adding the queue label first recovers that verdict's strict
|
|
854
|
+
payload and requires its `PROMOTABLE` result, then compares the lane admission
|
|
855
|
+
would enforce against the verdict's `fileLane` as deduplicated,
|
|
856
|
+
order-independent path sets — exact in both directions, so a missing path
|
|
857
|
+
refuses exactly like an extra one, and an order-only difference promotes. Any
|
|
858
|
+
disagreement refuses with `promotion-brief-mismatch`, naming both sets; so
|
|
859
|
+
does evidence behind a `promotable` row that does not recover as a strict
|
|
860
|
+
PROMOTABLE result, and an issue unreadable at promotion time behind such a
|
|
861
|
+
row. The durable verdict is what made the issue dispatchable, so the gate
|
|
862
|
+
fails closed before any tracker mutation or daemon wake — but it never edits
|
|
863
|
+
the issue itself: the remediation is applying the verdict's `proposedBrief`
|
|
864
|
+
to the issue (its `## Exact write lane` included), then adding the label
|
|
865
|
+
again. An issue without a `promotable` row promotes exactly as before.
|
|
866
|
+
|
|
851
867
|
#### What status shows
|
|
852
868
|
|
|
853
869
|
`status` renders the two lifecycle kinds as two blocks, because "a branch is
|
|
@@ -3099,7 +3115,7 @@ daemon, across a process boundary, not in a prompt the model can rewrite.
|
|
|
3099
3115
|
| `conductor_pr_status` | worker or orchestrator | Read-only — nothing to gate. A worker reads only its own run's PR; an orchestrator may name any syntactically valid PR URL, open, merged, or closed, and gets its live state and head (checks are reported when available; a merged or closed PR reports its state instead of an `expected OPEN` refusal). |
|
|
3100
3116
|
| `conductor_pr_update_branch` | orchestrator, or the worker owning the run | The PR belongs to this project and is open. A worker may only name its own run's PR. |
|
|
3101
3117
|
| `conductor_pr_merge` | **orchestrator only** | Ordinarily, `authority.merge` equals the caller. A hand-edited `recoveryMerges` entry may instead authorize one exact unrecorded PR/head/reason while held. In both paths, while the project has an [active release composition](#the-active-release-composition-850), the PR must be named in it or carry an explicit operator override; then `headSha` equals the live head *at execution time*; checks are green at that same SHA; no [review evidence](#the-exact-head-review-gate-888) stands at that head without a recorded `conductor_pr_review_clear`; the project route and migration chain are valid; the project's single merge slot is free. |
|
|
3102
|
-
| `conductor_label` | **orchestrator only** | The label is in the project's own vocabulary. Lifecycle labels stay the daemon's. Adding the queue label echoes the parsed [file lane](#the-file-lane-declaration), or refuses with `file-lane-unparseable` when a clearly delimited write-lane section parsed nothing — it never claims fail-open beside a declaration that was actually attempted. |
|
|
3118
|
+
| `conductor_label` | **orchestrator only** | The label is in the project's own vocabulary. Lifecycle labels stay the daemon's. Adding the queue label echoes the parsed [file lane](#the-file-lane-declaration), or refuses with `file-lane-unparseable` when a clearly delimited write-lane section parsed nothing — it never claims fail-open beside a declaration that was actually attempted. When the issue carries a durable `promotable` grooming verdict, adding the queue label also verifies the current brief against that verdict before any mutation: a brief that disagrees with the verdict's `fileLane` (or evidence that no longer recovers as a strict PROMOTABLE result) refuses with `promotion-brief-mismatch` — apply the verdict's `proposedBrief`, then re-add ([the file-lane declaration](#the-file-lane-declaration)). |
|
|
3103
3119
|
| `conductor_release` | **orchestrator only** | `authority.release` equals the caller; the per-shape grant permits it; the artefact or environment was declared; the release preconditions hold; the `reason` is in the closed enum. `version-bump-pr` creates or re-validates one deterministic version-only PR and, on a later call, merges only its exact green head through the project's single merge slot. |
|
|
3104
3120
|
| `conductor_install` | **orchestrator only** | Gated like a release act: the `install` shape defaults to `human` and a grant is what moves it. The daemon refuses a version npm does not expose with a full `gitHead`, refuses while another install is still in flight, and otherwise starts a detached transient unit that pauses, drains, installs the CLI/omp plugin/Herdr plugin and reloads — outside this session and the daemon. The unit never declares its own success; the first tick after the restart verifies and reports through the durable outbox. |
|
|
3105
3121
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
|
package/src/daemon.ts
CHANGED
|
@@ -64,6 +64,7 @@ import { inspectSurfaces, type InstalledSurfaces } from "./upgrade.ts";
|
|
|
64
64
|
import { checkTelegramFreshness, type TelegramFreshness } from "./telegram-freshness.ts";
|
|
65
65
|
import { processStartTimeMs } from "./upgrade-verify.ts";
|
|
66
66
|
import {
|
|
67
|
+
closeWorkerPane,
|
|
67
68
|
fleetLayers,
|
|
68
69
|
herdrPaneOmpStarts,
|
|
69
70
|
openWorkerPane,
|
|
@@ -73,6 +74,9 @@ import {
|
|
|
73
74
|
releaseWorkerPane,
|
|
74
75
|
reportWorkerPaneState,
|
|
75
76
|
resolveHerdrSession,
|
|
77
|
+
retireWorkerPane,
|
|
78
|
+
type HerdrRun,
|
|
79
|
+
type WorkspaceOwnership,
|
|
76
80
|
type WorkerPaneOutcome,
|
|
77
81
|
} from "./fleet.ts";
|
|
78
82
|
import { startOrchestrator } from "./orchestrator.ts";
|
|
@@ -340,6 +344,12 @@ interface Deps {
|
|
|
340
344
|
workerDeps?: RunWorkerDeps;
|
|
341
345
|
/** Harness per-process logs used to classify terminal worker routes. */
|
|
342
346
|
harnessLogDir?: string;
|
|
347
|
+
/**
|
|
348
|
+
* The Herdr runner for the worker-representation surface (#1035). Production
|
|
349
|
+
* leaves it absent — the real `herdr` CLI answers; lifecycle tests inject a
|
|
350
|
+
* fake so convergence is proven without a terminal.
|
|
351
|
+
*/
|
|
352
|
+
herdrRun?: HerdrRun;
|
|
343
353
|
integrity: IntegrityGate;
|
|
344
354
|
stall: StallGate;
|
|
345
355
|
/**
|
|
@@ -1912,6 +1922,17 @@ export async function handleIssue(
|
|
|
1912
1922
|
// for a worker that has already blocked.
|
|
1913
1923
|
let paneSeq = 0;
|
|
1914
1924
|
const nextPaneSeq = (): number => (paneSeq += 1);
|
|
1925
|
+
/**
|
|
1926
|
+
* The injected Herdr runner, when this daemon carries one (#1035); every
|
|
1927
|
+
* representation call below shares it so tests see one argv stream. The
|
|
1928
|
+
* ownership adapter is always present in production — the store-backed
|
|
1929
|
+
* half of workspace discovery, so what a Herdr restart forgets, the
|
|
1930
|
+
* durable row remembers (#1035 review).
|
|
1931
|
+
*/
|
|
1932
|
+
const herdr = {
|
|
1933
|
+
...(d.herdrRun === undefined ? {} : { run: d.herdrRun }),
|
|
1934
|
+
ownership: workspaceOwnership(d.store),
|
|
1935
|
+
};
|
|
1915
1936
|
/**
|
|
1916
1937
|
* Project one authoritative transition onto the pane (#842).
|
|
1917
1938
|
*
|
|
@@ -1922,6 +1943,7 @@ export async function handleIssue(
|
|
|
1922
1943
|
const projectPaneState = (state: "working" | "idle" | "blocked" | "unknown", message?: string): void => {
|
|
1923
1944
|
if (workerPane === undefined) return;
|
|
1924
1945
|
const reported = reportWorkerPaneState(workerPane.paneId, workerPane.label, state, {
|
|
1946
|
+
...herdr,
|
|
1925
1947
|
seq: nextPaneSeq(),
|
|
1926
1948
|
...(message === undefined ? {} : { message }),
|
|
1927
1949
|
});
|
|
@@ -2529,6 +2551,7 @@ export async function handleIssue(
|
|
|
2529
2551
|
workerPane = { kind: "tracked", paneId: recorded.paneId, label: recorded.paneLabel, pid };
|
|
2530
2552
|
store.updateRun(runId, { workerPid: pid });
|
|
2531
2553
|
const state = reportWorkerPaneState(recorded.paneId, recorded.paneLabel, "working", {
|
|
2554
|
+
...herdr,
|
|
2532
2555
|
seq: nextPaneSeq(),
|
|
2533
2556
|
});
|
|
2534
2557
|
log(
|
|
@@ -2538,14 +2561,17 @@ export async function handleIssue(
|
|
|
2538
2561
|
);
|
|
2539
2562
|
return;
|
|
2540
2563
|
}
|
|
2541
|
-
const outcome = openWorkerPane(
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2564
|
+
const outcome = openWorkerPane(
|
|
2565
|
+
{
|
|
2566
|
+
project: project.name,
|
|
2567
|
+
issue,
|
|
2568
|
+
attempt,
|
|
2569
|
+
runId,
|
|
2570
|
+
pid,
|
|
2571
|
+
...(recorded?.sessionFile === undefined ? {} : { sessionFile: recorded.sessionFile }),
|
|
2572
|
+
},
|
|
2573
|
+
herdr,
|
|
2574
|
+
);
|
|
2549
2575
|
if (outcome.kind === "tracked") {
|
|
2550
2576
|
workerPane = outcome;
|
|
2551
2577
|
// Durable before it is announced: a pane the store does not know
|
|
@@ -2569,8 +2595,14 @@ export async function handleIssue(
|
|
|
2569
2595
|
},
|
|
2570
2596
|
release: () => {
|
|
2571
2597
|
if (workerPane === undefined) return;
|
|
2572
|
-
|
|
2573
|
-
|
|
2598
|
+
// Retirement, not release alone (#1035): a settled run keeps no
|
|
2599
|
+
// representation. The close reaches only the follower; the
|
|
2600
|
+
// authoritative child was never in the pane to be signalled.
|
|
2601
|
+
const retired = retireWorkerPane(workerPane.paneId, workerPane.label, {
|
|
2602
|
+
...herdr,
|
|
2603
|
+
seq: nextPaneSeq(),
|
|
2604
|
+
});
|
|
2605
|
+
if (!retired.ok) log(`#${issue} herdr pane retirement failed: ${retired.reason}`);
|
|
2574
2606
|
workerPane = undefined;
|
|
2575
2607
|
},
|
|
2576
2608
|
},
|
|
@@ -3065,17 +3097,19 @@ export async function handleIssue(
|
|
|
3065
3097
|
* losing a dispatch pass over a version string would be absurd.
|
|
3066
3098
|
*/
|
|
3067
3099
|
/**
|
|
3068
|
-
* Make the
|
|
3100
|
+
* Make the worker workspaces agree with the live run set, once per pass
|
|
3101
|
+
* (#841, reworked by #1035).
|
|
3069
3102
|
*
|
|
3070
3103
|
* Runs on every dispatch pass rather than only at startup, because the thing it
|
|
3071
3104
|
* repairs — a Herdr restart — is not a conductor event and announces itself
|
|
3072
3105
|
* nowhere. It is idempotent by construction: a reconciled fleet reports `intact`
|
|
3073
|
-
* for every live worker and finds
|
|
3074
|
-
* instead of accumulating panes or churning them.
|
|
3106
|
+
* for every live worker and finds nothing left to close, so repeated passes
|
|
3107
|
+
* converge instead of accumulating panes or churning them. This pass owns this
|
|
3108
|
+
* project's worker workspace even when no run is live — that is how a settled-out
|
|
3109
|
+
* project's stale shells converge to zero and its empty workspace is removed.
|
|
3075
3110
|
*
|
|
3076
|
-
* Nothing here can stop a worker
|
|
3077
|
-
*
|
|
3078
|
-
* signalled, and no pane is ever closed.
|
|
3111
|
+
* Nothing here can stop a worker: panes hold followers only; the authoritative
|
|
3112
|
+
* child lives in this daemon's process tree and is never signalled.
|
|
3079
3113
|
*/
|
|
3080
3114
|
/**
|
|
3081
3115
|
* Pane re-establishment attempts spent per run, for this daemon process only
|
|
@@ -3089,7 +3123,25 @@ export async function handleIssue(
|
|
|
3089
3123
|
*/
|
|
3090
3124
|
const paneAttemptsSpent = new Map<string, number>();
|
|
3091
3125
|
|
|
3092
|
-
|
|
3126
|
+
/** Exported for the lifecycle tests (#1035): the pass is pure over the store
|
|
3127
|
+
* and the injected Herdr runner, so a test drives it exactly as the tick does. */
|
|
3128
|
+
/** The store-backed {@link WorkspaceOwnership} adapter (#1035 review): the
|
|
3129
|
+
* conductor store is the durable leg of workspace discovery, surviving both
|
|
3130
|
+
* daemon and Herdr restarts. One shape for every caller so they cannot
|
|
3131
|
+
* disagree about what ownership reads. */
|
|
3132
|
+
function workspaceOwnership(store: Deps["store"]): WorkspaceOwnership {
|
|
3133
|
+
return {
|
|
3134
|
+
recordedWorkspaces: (project) => store.workerWorkspaceIds(project),
|
|
3135
|
+
rememberWorkspace: (project, workspaceId) => store.rememberWorkerWorkspace(project, workspaceId),
|
|
3136
|
+
forgetWorkspace: (project, workspaceId) => store.forgetWorkerWorkspace(project, workspaceId),
|
|
3137
|
+
};
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
export function reconcilePanes(
|
|
3141
|
+
d: Pick<Deps, "store" | "herdrRun">,
|
|
3142
|
+
project: string,
|
|
3143
|
+
log: (message: string) => void,
|
|
3144
|
+
): void {
|
|
3093
3145
|
const live = d.store.liveRuns(project);
|
|
3094
3146
|
const liveKeys = new Set(live.map((run) => `${project}\u0000${run.id}`));
|
|
3095
3147
|
for (const key of [...paneAttemptsSpent.keys()]) {
|
|
@@ -3109,8 +3161,10 @@ function reconcilePanes(d: Deps, project: string, log: (message: string) => void
|
|
|
3109
3161
|
...(run.paneLabel === undefined ? {} : { paneLabel: run.paneLabel }),
|
|
3110
3162
|
...(run.sessionFile === undefined ? {} : { sessionFile: run.sessionFile }),
|
|
3111
3163
|
})),
|
|
3112
|
-
{},
|
|
3164
|
+
{ ...(d.herdrRun === undefined ? {} : { run: d.herdrRun }), ownership: workspaceOwnership(d.store) },
|
|
3113
3165
|
attempts,
|
|
3166
|
+
PANE_REATTEMPT_MAX,
|
|
3167
|
+
[project],
|
|
3114
3168
|
);
|
|
3115
3169
|
if (!result.ok) {
|
|
3116
3170
|
// An unreadable workspace is not evidence that anything is stale, so nothing
|
|
@@ -3123,6 +3177,14 @@ function reconcilePanes(d: Deps, project: string, log: (message: string) => void
|
|
|
3123
3177
|
switch (outcome.kind) {
|
|
3124
3178
|
case "intact":
|
|
3125
3179
|
break;
|
|
3180
|
+
case "visual-unreadable":
|
|
3181
|
+
// The pane keeps standing: an unreadable follower table is no evidence
|
|
3182
|
+
// of death, and a live worker's representation is never destroyed on a
|
|
3183
|
+
// guess (#1035 review). Said once per pass, not acted on.
|
|
3184
|
+
log(
|
|
3185
|
+
`#${runIssue(live, outcome.runId)} herdr pane ${outcome.paneId} visual unreadable, left standing: ${outcome.reason}`,
|
|
3186
|
+
);
|
|
3187
|
+
break;
|
|
3126
3188
|
case "reassociated":
|
|
3127
3189
|
d.store.updateRun(outcome.runId, {
|
|
3128
3190
|
paneId: outcome.paneId,
|
|
@@ -3139,11 +3201,11 @@ function reconcilePanes(d: Deps, project: string, log: (message: string) => void
|
|
|
3139
3201
|
break;
|
|
3140
3202
|
}
|
|
3141
3203
|
// #998: a terminal refusing to split will refuse the next one too, and
|
|
3142
|
-
// each retry made the pile worse — spend the whole budget at once.
|
|
3204
|
+
// each retry made the pile worse — spend the whole budget at once. The
|
|
3205
|
+
// phase is structured now (#1035 review): the old substring sniff died
|
|
3206
|
+
// when the reason strings changed and never fired again.
|
|
3143
3207
|
const key = `${project}\u0000${outcome.runId}`;
|
|
3144
|
-
const spent = outcome.
|
|
3145
|
-
? PANE_REATTEMPT_MAX
|
|
3146
|
-
: (paneAttemptsSpent.get(key) ?? 0) + 1;
|
|
3208
|
+
const spent = outcome.phase === "split" ? PANE_REATTEMPT_MAX : (paneAttemptsSpent.get(key) ?? 0) + 1;
|
|
3147
3209
|
paneAttemptsSpent.set(key, spent);
|
|
3148
3210
|
// Once per run, at the moment the budget runs out — not once per pass.
|
|
3149
3211
|
// Four identical lines a minute for #986 is what made the real cause
|
|
@@ -3159,11 +3221,33 @@ function reconcilePanes(d: Deps, project: string, log: (message: string) => void
|
|
|
3159
3221
|
// Already reported when the budget ran out; the run keeps working with
|
|
3160
3222
|
// no pane, and a later restart gets a fresh budget.
|
|
3161
3223
|
break;
|
|
3162
|
-
case "stale-released":
|
|
3163
|
-
|
|
3224
|
+
case "stale-released": {
|
|
3225
|
+
const what =
|
|
3226
|
+
outcome.cause === "duplicate"
|
|
3227
|
+
? "closed duplicate herdr pane"
|
|
3228
|
+
: outcome.cause === "unidentified"
|
|
3229
|
+
? "closed unidentified herdr pane"
|
|
3230
|
+
: "released settled herdr pane";
|
|
3231
|
+
log(`${what} ${outcome.paneId} (run ${outcome.runId || "unidentified"}${outcome.cause === "settled" ? " is not live" : ""})`);
|
|
3232
|
+
break;
|
|
3233
|
+
}
|
|
3234
|
+
case "stale-release-failed": {
|
|
3235
|
+
const what =
|
|
3236
|
+
outcome.cause === "duplicate"
|
|
3237
|
+
? "duplicate herdr pane"
|
|
3238
|
+
: outcome.cause === "unidentified"
|
|
3239
|
+
? "unidentified herdr pane"
|
|
3240
|
+
: "settled herdr pane";
|
|
3241
|
+
log(`${what} ${outcome.paneId} could not be closed: ${outcome.reason}`);
|
|
3242
|
+
break;
|
|
3243
|
+
}
|
|
3244
|
+
case "workspace-removed":
|
|
3245
|
+
log(`removed empty herdr worker workspace ${outcome.workspaceId} for ${outcome.project}`);
|
|
3164
3246
|
break;
|
|
3165
|
-
case "
|
|
3166
|
-
log(
|
|
3247
|
+
case "workspace-remove-failed":
|
|
3248
|
+
log(
|
|
3249
|
+
`herdr worker workspace ${outcome.workspaceId} for ${outcome.project} could not be removed: ${outcome.reason}`,
|
|
3250
|
+
);
|
|
3167
3251
|
break;
|
|
3168
3252
|
}
|
|
3169
3253
|
}
|
|
@@ -7430,15 +7514,22 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
7430
7514
|
// The workspace outlives the process, so a restart inherits panes whose
|
|
7431
7515
|
// workers are gone (#842). A session-host child dies with the daemon that
|
|
7432
7516
|
// owned its socket, so an orphaned row's pane is authoritatively dead
|
|
7433
|
-
// whatever its recorded pid says
|
|
7434
|
-
//
|
|
7435
|
-
//
|
|
7436
|
-
//
|
|
7437
|
-
|
|
7517
|
+
// whatever its recorded pid says. Ownership is proven before the close
|
|
7518
|
+
// (#1035 review): pane ids are reused after a Herdr restart, so only a
|
|
7519
|
+
// pane inside a workspace conductor owns — token-marked or store-recorded,
|
|
7520
|
+
// which is what survives Herdr dropping tokens on restore — whose
|
|
7521
|
+
// reported identity still agrees with this row is retired. An unowned
|
|
7522
|
+
// hit closes nothing and says so.
|
|
7523
|
+
const orphanPane = releaseOrphanedWorkerPane(
|
|
7524
|
+
{ paneId: run.paneId, paneLabel: run.paneLabel, runId: run.id, project: run.project },
|
|
7525
|
+
{ ownership: workspaceOwnership(store) },
|
|
7526
|
+
);
|
|
7438
7527
|
if (orphanPane.kind === "released") {
|
|
7439
7528
|
projectLog(
|
|
7440
|
-
`#${run.issue} herdr pane ${orphanPane.paneId}
|
|
7529
|
+
`#${run.issue} herdr pane ${orphanPane.paneId} retired: its worker died with the previous daemon`,
|
|
7441
7530
|
);
|
|
7531
|
+
} else if (orphanPane.kind === "unowned") {
|
|
7532
|
+
projectLog(`#${run.issue} herdr pane ${orphanPane.paneId} left alone: ${orphanPane.reason}`);
|
|
7442
7533
|
} else if (orphanPane.kind === "failed") {
|
|
7443
7534
|
projectLog(`#${run.issue} herdr pane ${orphanPane.paneId} release failed: ${orphanPane.reason}`);
|
|
7444
7535
|
}
|
package/src/doctor.ts
CHANGED
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
claimedTelegramTopics,
|
|
69
69
|
lockPidAlive,
|
|
70
70
|
pidAlive,
|
|
71
|
+
killOf,
|
|
71
72
|
readTelegramChannel,
|
|
72
73
|
readTelegramDmOwner,
|
|
73
74
|
readTelegramPollState,
|
|
@@ -311,12 +312,11 @@ export interface DoctorDeps {
|
|
|
311
312
|
* claim-only verdict checks only. */
|
|
312
313
|
armScanDirs?: (project: ProjectConfig) => readonly string[];
|
|
313
314
|
/** Whether a recorded claim or dm-owner pid is live, with omp-telegram's
|
|
314
|
-
* topics.ts semantics (EPERM is dead).
|
|
315
|
+
* topics.ts semantics (EPERM is dead). The one claim-liveness fact in this
|
|
316
|
+
* package: topic-pin's dead partition, its exact-pin check, project
|
|
317
|
+
* resolution and the telegram-plumbing verdict all derive from it, so two
|
|
318
|
+
* rows cannot answer one claim differently (#987). */
|
|
315
319
|
pidAlive?: (pid: number) => boolean;
|
|
316
|
-
/** Whether one claimed topic is live — the single fact `topic-pin` and
|
|
317
|
-
* `telegram-plumbing` both read, so they cannot disagree about a claim
|
|
318
|
-
* (#987). Defaults to {@link claimIsLive}. */
|
|
319
|
-
claimIsLive?: (claim: { pid?: number }) => boolean;
|
|
320
320
|
/** The provider allowance window nearest its ceiling, from `omp usage --json`
|
|
321
321
|
* — what binds a subscription-billed fleet instead of a dollar cap (#984).
|
|
322
322
|
* `undefined` when the provider reports no comparable window. */
|
|
@@ -1401,12 +1401,16 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
|
1401
1401
|
`check omp-telegram's claim registry (threads.json in its state dir) is readable and the bridge is running, then re-run doctor — until then sends keep the pinned topic and degrade to the flat chat on a missing thread (#318)`,
|
|
1402
1402
|
);
|
|
1403
1403
|
}
|
|
1404
|
-
// One liveness fact
|
|
1405
|
-
//
|
|
1406
|
-
//
|
|
1404
|
+
// One liveness fact for everything this probe decides — the dead partition,
|
|
1405
|
+
// the exact-pin check below and identity resolution all read
|
|
1406
|
+
// `probes.pidAlive` through the same helper, so diverging injections cannot
|
|
1407
|
+
// split the rows' answer again (#987). Before #987 every registry row was
|
|
1408
|
+
// treated as live here, so a pin aimed at a corpse read PASS while the
|
|
1409
|
+
// plumbing row called the same claim dead.
|
|
1410
|
+
const isLive = (claim: { pid?: number }): boolean => claimIsLive(claim, killOf(probes.pidAlive));
|
|
1407
1411
|
const all = result.claims;
|
|
1408
|
-
const claims = all.filter(
|
|
1409
|
-
const dead = all.filter((claim) => !
|
|
1412
|
+
const claims = all.filter(isLive);
|
|
1413
|
+
const dead = all.filter((claim) => !isLive(claim));
|
|
1410
1414
|
// Named, never dropped: the row is the only index to the remote topic, so
|
|
1411
1415
|
// deleting it strands the topic instead of cleaning it up.
|
|
1412
1416
|
const deadNote =
|
|
@@ -1428,10 +1432,12 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
|
1428
1432
|
`[${p.name}] pinned topic ${pinned} — no live claims to compare (the bridge has claimed no topics yet); the pin stands`,
|
|
1429
1433
|
);
|
|
1430
1434
|
}
|
|
1435
|
+
// `claims` is already the live partition, so a dead pinned row cannot win
|
|
1436
|
+
// this check (#987).
|
|
1431
1437
|
if (claims.some((claim) => claim.threadId === pinned)) {
|
|
1432
1438
|
return passFinding("topic-pin", `[${p.name}] pinned topic ${pinned} is a live claim${deadNote}`);
|
|
1433
1439
|
}
|
|
1434
|
-
const match = resolveProjectClaim(claims, p.name);
|
|
1440
|
+
const match = resolveProjectClaim(claims, p.name, probes.pidAlive);
|
|
1435
1441
|
if (match.kind === "match") {
|
|
1436
1442
|
return passFinding(
|
|
1437
1443
|
"topic-pin",
|
|
@@ -2192,7 +2198,6 @@ export function defaultProbes(): Probes {
|
|
|
2192
2198
|
return claimDir === cwdDir ? [cwdDir] : [cwdDir, claimDir];
|
|
2193
2199
|
},
|
|
2194
2200
|
pidAlive,
|
|
2195
|
-
claimIsLive: (claim) => claimIsLive(claim),
|
|
2196
2201
|
allowanceWindow: async () => bindingAllowanceWindow(await sharedUsageSource().read()),
|
|
2197
2202
|
lockPidAlive,
|
|
2198
2203
|
lockFresh: (mtimeMs) => Date.now() - mtimeMs < TELEGRAM_LOCK_FRESH_MS,
|
package/src/escalate.ts
CHANGED
|
@@ -467,11 +467,13 @@ export interface ClaimedTopic {
|
|
|
467
467
|
* own maintenance, and every topic-addressed send silently degrades to the main
|
|
468
468
|
* chat: tier-2 pages, reports, digests, arm challenges, direct messages (#407).
|
|
469
469
|
*
|
|
470
|
-
* The operator's pin still wins whenever it is live. Only a pin that
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
470
|
+
* The operator's pin still wins whenever it is live. Only a pin that no *live*
|
|
471
|
+
* claim carries is replaced, and only by the claim this project can be
|
|
472
|
+
* identified with — so a deliberately separate alerts topic is never hijacked
|
|
473
|
+
* by the pane's own thread. A dead row wearing the pin id does not keep it in
|
|
474
|
+
* place: the row is the bridge's past, not a destination (#987). Unavailable
|
|
475
|
+
* bridge state changes nothing, and #318's stale-topic retry remains the last
|
|
476
|
+
* line of defence.
|
|
475
477
|
*
|
|
476
478
|
* Identity is read from the herdr space first, and only then from the claim's
|
|
477
479
|
* title. The bridge titles a topic `ownAgentName ?? basename(cwd)`, and both
|
|
@@ -483,14 +485,19 @@ export interface ClaimedTopic {
|
|
|
483
485
|
* The substitution is logged, naming the project and which identity answered.
|
|
484
486
|
* Never an id: a log line is a place these leak from.
|
|
485
487
|
*/
|
|
486
|
-
export function resolveProjectTopicId(project: ProjectConfig): number | undefined {
|
|
488
|
+
export function resolveProjectTopicId(project: ProjectConfig, alive?: (pid: number) => boolean): number | undefined {
|
|
487
489
|
const pinned = project.escalation.telegramTopicId;
|
|
488
490
|
if (pinned === undefined) return undefined;
|
|
489
491
|
const result = claimedTelegramTopics();
|
|
490
492
|
if (result.kind !== "ok" || result.claims.length === 0) return pinned;
|
|
491
493
|
const claims = result.claims;
|
|
492
|
-
|
|
493
|
-
|
|
494
|
+
// The pin wins only while a *live* claim carries it; a dead pinned row falls
|
|
495
|
+
// through to the live-claim substitution below instead of winning the
|
|
496
|
+
// exact-pin check (#987).
|
|
497
|
+
if (claims.some((claim) => claim.threadId === pinned && claimIsLive(claim, alive === undefined ? undefined : killOf(alive)))) {
|
|
498
|
+
return pinned;
|
|
499
|
+
}
|
|
500
|
+
const match = claimForProject(claims, project.name, alive);
|
|
494
501
|
if (match === undefined) return pinned;
|
|
495
502
|
warn(
|
|
496
503
|
`escalation.telegramTopicId for ${project.name} is no longer a claimed topic; ` +
|
|
@@ -519,25 +526,32 @@ export type ProjectClaim =
|
|
|
519
526
|
* How the live claims answer to a project, identity spellings in order:
|
|
520
527
|
* unique herdr space, then unique title.
|
|
521
528
|
*
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
+
* Only live claims take part ({@link claimIsLive}): a dead row neither
|
|
530
|
+
* answers on its own nor turns this project's one live pane into an
|
|
531
|
+
* ambiguity (#987). Ambiguity is not a coin toss: paging the wrong project's
|
|
532
|
+
* topic is worse than the flat-chat degrade #318 already handles, and
|
|
533
|
+
* scanning the wrong pane's session misses the reply entirely. The title is
|
|
534
|
+
* a fallback for bridges that never captured a space, so it runs only when
|
|
535
|
+
* *no* live claim carries the project's space — several live claims wearing
|
|
536
|
+
* the space is an ambiguity the title cannot resolve, because the titled
|
|
537
|
+
* claim may be a sibling pane, not this project (#626).
|
|
529
538
|
*/
|
|
530
539
|
export function resolveProjectClaim(
|
|
531
540
|
claims: readonly ClaimedTopic[],
|
|
532
541
|
projectName: string,
|
|
542
|
+
alive?: (pid: number) => boolean,
|
|
533
543
|
): ProjectClaim {
|
|
534
|
-
|
|
544
|
+
// Dead claims do not vote: a closed pane's row must not turn this project's
|
|
545
|
+
// one live claim into an ambiguity, and must not answer by title on its own
|
|
546
|
+
// (#987). Ambiguity among genuinely live claims is unchanged (#626).
|
|
547
|
+
const live = claims.filter((claim) => claimIsLive(claim, alive === undefined ? undefined : killOf(alive)));
|
|
548
|
+
const bySpace = live.filter((claim) => claim.workspaceLabel === projectName);
|
|
535
549
|
if (bySpace.length > 0) {
|
|
536
550
|
return bySpace.length === 1
|
|
537
551
|
? { kind: "match", claim: bySpace[0]! }
|
|
538
552
|
: { kind: "ambiguous", claimants: bySpace };
|
|
539
553
|
}
|
|
540
|
-
const byTitle =
|
|
554
|
+
const byTitle = live.filter((claim) => claim.name === projectName);
|
|
541
555
|
if (byTitle.length === 0) return { kind: "none" };
|
|
542
556
|
return byTitle.length === 1
|
|
543
557
|
? { kind: "match", claim: byTitle[0]! }
|
|
@@ -556,8 +570,9 @@ export function resolveProjectClaim(
|
|
|
556
570
|
export function claimForProject(
|
|
557
571
|
claims: readonly ClaimedTopic[],
|
|
558
572
|
projectName: string,
|
|
573
|
+
alive?: (pid: number) => boolean,
|
|
559
574
|
): ClaimedTopic | undefined {
|
|
560
|
-
const match = resolveProjectClaim(claims, projectName);
|
|
575
|
+
const match = resolveProjectClaim(claims, projectName, alive);
|
|
561
576
|
return match.kind === "match" ? match.claim : undefined;
|
|
562
577
|
}
|
|
563
578
|
|
|
@@ -574,10 +589,13 @@ export function claimForProject(
|
|
|
574
589
|
* the claim names no file — the caller then falls back to the cwd-derived
|
|
575
590
|
* directory, which is the honest answer for a host that predates claims.
|
|
576
591
|
*/
|
|
577
|
-
export function resolveClaimedSessionFile(
|
|
592
|
+
export function resolveClaimedSessionFile(
|
|
593
|
+
project: ProjectConfig,
|
|
594
|
+
alive?: (pid: number) => boolean,
|
|
595
|
+
): string | undefined {
|
|
578
596
|
const result = claimedTelegramTopics();
|
|
579
597
|
if (result.kind !== "ok" || result.claims.length === 0) return undefined;
|
|
580
|
-
return claimForProject(result.claims, project.name)?.sessionFile;
|
|
598
|
+
return claimForProject(result.claims, project.name, alive)?.sessionFile;
|
|
581
599
|
}
|
|
582
600
|
|
|
583
601
|
/**
|
|
@@ -1111,7 +1129,7 @@ export type TelegramPlumbingScan = { dirs: readonly string[] };
|
|
|
1111
1129
|
* Throwing is how `pidAlive` spells "dead", which is the contract it applies to
|
|
1112
1130
|
* whatever this returns.
|
|
1113
1131
|
*/
|
|
1114
|
-
function killOf(alive: (pid: number) => boolean): (target: number, signal: number) => void {
|
|
1132
|
+
export function killOf(alive: (pid: number) => boolean): (target: number, signal: number) => void {
|
|
1115
1133
|
return (target) => {
|
|
1116
1134
|
if (!alive(target)) throw new Error("not alive");
|
|
1117
1135
|
};
|