tickmarkr 2.6.4 → 2.6.5
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/dist/adapters/catalog-remote.d.ts +23 -0
- package/dist/adapters/catalog-remote.js +38 -0
- package/dist/adapters/claude-code.d.ts +1 -1
- package/dist/adapters/claude-code.js +4 -1
- package/dist/adapters/codex-commit-check.d.ts +25 -0
- package/dist/adapters/codex-commit-check.js +108 -0
- package/dist/adapters/model-discovery-check.d.ts +33 -0
- package/dist/adapters/model-discovery-check.js +123 -0
- package/dist/adapters/model-lints.d.ts +1 -1
- package/dist/adapters/model-lints.js +4 -0
- package/dist/adapters/prompt.js +3 -2
- package/dist/adapters/types.d.ts +7 -0
- package/dist/cli/commands/approve.js +10 -4
- package/dist/cli/commands/doctor.d.ts +2 -0
- package/dist/cli/commands/doctor.js +14 -2
- package/dist/cli/commands/fleet.d.ts +10 -1
- package/dist/cli/commands/fleet.js +55 -38
- package/dist/cli/commands/init.js +5 -1
- package/dist/cli/commands/plan.js +65 -4
- package/dist/cli/commands/verify.d.ts +1 -1
- package/dist/cli/commands/verify.js +28 -6
- package/dist/config/fleet-overlay.d.ts +2 -1
- package/dist/config/fleet-overlay.js +77 -4
- package/dist/drivers/orca.d.ts +19 -1
- package/dist/drivers/orca.js +161 -31
- package/dist/gates/cache.d.ts +4 -0
- package/dist/gates/cache.js +10 -2
- package/dist/gates/llm.d.ts +4 -1
- package/dist/gates/llm.js +6 -1
- package/dist/gates/review.js +8 -3
- package/dist/gates/run-gates.d.ts +3 -2
- package/dist/gates/run-gates.js +91 -50
- package/dist/run/daemon.js +150 -19
- package/dist/run/git.js +9 -0
- package/dist/run/journal.d.ts +6 -2
- package/dist/run/journal.js +22 -7
- package/dist/run/launchservices-check.d.ts +56 -0
- package/dist/run/launchservices-check.js +117 -0
- package/dist/run/operator-state.d.ts +22 -2
- package/dist/run/operator-state.js +20 -3
- package/dist/run/title-environment.d.ts +45 -0
- package/dist/run/title-environment.js +127 -0
- package/dist/run/title-preload.cjs +38 -0
- package/dist/run/title-preload.d.cts +1 -0
- package/dist/tui/cockpit/board.js +12 -6
- package/dist/tui/cockpit/keys.d.ts +8 -0
- package/dist/tui/cockpit/keys.js +4 -1
- package/dist/tui/cockpit/layout.d.ts +2 -2
- package/dist/tui/cockpit/layout.js +2 -2
- package/dist/tui/cockpit/live-runtime.js +46 -20
- package/dist/tui/cockpit/live-store.d.ts +24 -1
- package/dist/tui/cockpit/live-store.js +99 -8
- package/dist/tui/cockpit/log-view.d.ts +28 -0
- package/dist/tui/cockpit/log-view.js +60 -0
- package/dist/tui/cockpit/run-view.js +4 -0
- package/dist/tui/cockpit/shell.d.ts +2 -1
- package/dist/tui/cockpit/shell.js +6 -4
- package/dist/tui/ink/fleet-app.d.ts +12 -6
- package/dist/tui/ink/fleet-app.js +17 -11
- package/package.json +1 -1
- package/skills/tickmarkr-auto/SKILL.md +32 -2
- package/skills/tickmarkr-loop/SKILL.md +46 -2
- package/skills/tickmarkr-overseer/SKILL.md +83 -8
package/dist/run/daemon.js
CHANGED
|
@@ -36,7 +36,7 @@ import { changeRepresented, cleanupRunWorktrees, deriveForkCap, FORK_CAP_ENV, gi
|
|
|
36
36
|
import { runInteractiveSeed } from "./interactive-seed.js";
|
|
37
37
|
import { classifyRepairDisposition, resolveScopeHints } from "./repair-disposition.js";
|
|
38
38
|
import { applyScopeAmendments, activeRetryBan, interruptedAttempt, RESUME_HARVEST_SOURCE, resumeHarvestAuthor, approvalAction, APPROVAL_REFUSED, bindingToken, effectiveEvents, foldDecisions, physicalLine, staleApprovals, classifyTaskFailure, classifyWorkerResultCause, deferredReviewFindings, engagementComparable, formatPriorFindingEvidence, GATE_FINGERPRINT_CAP, GATE_SATISFIED_RELEASE, identicalGateFailures, isDeferredFinding, journaledFailureBrief, Journal, loadRoutingProfile, newRunId, normalizeGateFailure, outstandingConsultGuidance, readableExcerpt, receiptOrigin, outstandingReviewFindings, pendingApprovalActions, pendingRechecks, pendingRepairFindings, phaseForGate, priorJudgments, readPriorRunEvidence, recordedTaskFailureKind, reraisedReviewChains, RECHECK_RELEASE, renderStructuredReviewFinding, repairReachSinceApproval, repairsSinceApproval, reviewRoundsSinceApproval, runHasEnded, standingRulings, structuredFindings, upheldFeedbackByTask } from "./journal.js";
|
|
39
|
-
import { gateReviewerFloor, isDiffCapPark, isGarbageReview, pickReviewer } from "../gates/review.js";
|
|
39
|
+
import { carriedAuthorVendors, gateReviewerFloor, isDiffCapPark, isGarbageReview, pickReviewer } from "../gates/review.js";
|
|
40
40
|
import { acquireApprovalSerialization, acquireRunLock, isPidLive, releaseRunLock } from "./lock.js";
|
|
41
41
|
import { ensureIntegration, integrationBranch, integrationHead, mergeTask, reusedTipEvidence, verifyIntegrationTip } from "./merge.js";
|
|
42
42
|
import { channelExclusion, climbChannel, inTaskPool, marginalCostRank, nextChannel, poolExhaustion, route, taskPool } from "../route/router.js";
|
|
@@ -377,6 +377,49 @@ function classifyInfraResult(g) {
|
|
|
377
377
|
const TIMEOUT_SHAPED_RE = /\b(?:Test|Hook) timed out in (?:\d+|#)\s*ms\b|\bexpected (?:\d+|#)(?:\.(?:\d+|#))? to be (?:less than|below)(?: or equal to)? (?:\d+|#)/i;
|
|
378
378
|
const RUNNER_INFRA_DIAGNOSTIC_RE = /\bnever-started (?:[1-9]\d*|#)(?![\d#])|\[vitest-worker\]: Timeout calling\b/;
|
|
379
379
|
const timeoutShaped = (details) => TIMEOUT_SHAPED_RE.test(readableExcerpt(details));
|
|
380
|
+
/** C (E1): the one no-verdict that is the host's slowness, not the seat's — the same predicate run-gates
|
|
381
|
+
* applies to its run-scoped tally, read here for the live task strike and the resume reconstruction. */
|
|
382
|
+
const proofTimeoutNote = (data) => data.cause === "seat-launch-failed" && data.launchCause === "checkout-proof-timeout";
|
|
383
|
+
/** C: the gate-fresh-forced reason that bounds the out-of-scope re-execution, one per journaled subject. */
|
|
384
|
+
const OUTSIDE_SCOPE_RED = "no-commit-out-of-scope-red";
|
|
385
|
+
const NAMED_FAILURE_FILE_RE = /^\s*(\S+\.(?:test|spec)\.[cm]?[jt]sx?)\s+>/gm;
|
|
386
|
+
/** D-854: the runner certificate the vitest manifest path appends to a red (test-manifest.ts). */
|
|
387
|
+
const RUNNER_CERTIFICATE_RE = /runner-level diagnostic: never-started ([^;\s]+); reporter errors ([^;\s]+)/g;
|
|
388
|
+
/** C: a journaled test red whose failing-file attribution is nonempty, trustworthy and complete (repair
|
|
389
|
+
* selection's own rule: a behavioral red on a real subject from a full suite or a screen that names
|
|
390
|
+
* every failure), POSITIVELY certified complete by its runner, uncontradicted by the files its own text
|
|
391
|
+
* names, and wholly outside a declared files[]. Empty files[] is unrestricted and never qualifies;
|
|
392
|
+
* every other shape keeps its replay. */
|
|
393
|
+
function outsideScopeRed(t, taskId, data, commit) {
|
|
394
|
+
if (!t.files.length || data.gate !== "test" || data.pass !== false || data.skipped === true || data.commit !== commit
|
|
395
|
+
// a failed producer — an infra row, a dirty-tree refusal, a recovery the runner could not finish —
|
|
396
|
+
// never attributes anything, whatever files it lists
|
|
397
|
+
|| data.infra === true || data.dirtyWorktree === true || data.recoveryBlocked !== undefined)
|
|
398
|
+
return false;
|
|
399
|
+
const attribution = repairSelectionDecision([{ event: "gate-result", taskId, data }], taskId, true);
|
|
400
|
+
if (attribution.reason !== "known-failing-files" || attribution.requiredFiles.length === 0)
|
|
401
|
+
return false;
|
|
402
|
+
const text = readableExcerpt(String(data.details ?? ""));
|
|
403
|
+
// D-854: validate, never filter. A collection failure has no module record, so failingFiles omits it
|
|
404
|
+
// and it lives only in the certificate's counts; any never-started file or reporter error, an unknown
|
|
405
|
+
// count, or no certificate at all leaves the attribution unproven.
|
|
406
|
+
// ponytail: a nonzero count never qualifies, even when each diagnostic names an outside file — the
|
|
407
|
+
// details interleave diagnostics with the stdout tail; map them only if outside-scope collection reds need it.
|
|
408
|
+
const certificates = [...text.matchAll(RUNNER_CERTIFICATE_RE)];
|
|
409
|
+
if (!certificates.length || certificates.some((m) => m[1] !== "0" || m[2] !== "0"))
|
|
410
|
+
return false;
|
|
411
|
+
const named = [...text.matchAll(ATTRIBUTED_FILE_RE), ...text.matchAll(NAMED_FAILURE_FILE_RE)].map((m) => m[1].replace(/^\.\//, ""));
|
|
412
|
+
if (named.some((file) => !attribution.requiredFiles.includes(file)))
|
|
413
|
+
return false; // contradictory
|
|
414
|
+
const inScope = filesGlob(t.files);
|
|
415
|
+
return attribution.requiredFiles.every((file) => !inScope(file));
|
|
416
|
+
}
|
|
417
|
+
/** C: every test red a round journaled on `commit` is an out-of-scope red (none means none). */
|
|
418
|
+
const allOutsideScopeReds = (t, rows, commit) => {
|
|
419
|
+
const reds = rows.filter((e) => e.event === "gate-result" && e.data.gate === "test" && e.data.pass === false
|
|
420
|
+
&& e.data.skipped !== true && e.data.commit === commit);
|
|
421
|
+
return reds.length > 0 && reds.every((e) => outsideScopeRed(t, e.taskId ?? t.id, e.data, commit));
|
|
422
|
+
};
|
|
380
423
|
const ATTRIBUTED_FILE_RE = /^\s*(?:FAIL|×|✗)\s+(?:\|[^|\n]*\|\s+)?(\S+\.(?:test|spec)\.[cm]?[jt]sx?)\b/gm;
|
|
381
424
|
/** The failing files a red attributes to itself: the runner's own list, else its FAIL headlines. */
|
|
382
425
|
const attributedFailingFiles = (g) => [...new Set(Array.isArray(g.meta?.failingFiles) && g.meta.failingFiles.length
|
|
@@ -2203,10 +2246,13 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2203
2246
|
// is what bounds a seat that flakes once per task (or once per session) across the run. Seeded from the
|
|
2204
2247
|
// journal's review-no-verdict rows — the same notes noteReviewEvent appends — so a resumed daemon keeps
|
|
2205
2248
|
// the count; run-gates appends to it in place, exactly as it does demotedReviewers.
|
|
2249
|
+
// C (E1): a checkout-proof timeout never struck the live tally (run-gates), so a resume never seeds one.
|
|
2206
2250
|
const reviewNoVerdicts = new Map();
|
|
2207
2251
|
for (const event of journal.read()) {
|
|
2208
2252
|
if (event.event !== "review-no-verdict" || event.data.noVerdict !== true || typeof event.data.reviewer !== "string")
|
|
2209
2253
|
continue;
|
|
2254
|
+
if (proofTimeoutNote(event.data))
|
|
2255
|
+
continue;
|
|
2210
2256
|
reviewNoVerdicts.set(event.data.reviewer, [...(reviewNoVerdicts.get(event.data.reviewer) ?? []), String(event.data.cause)]);
|
|
2211
2257
|
}
|
|
2212
2258
|
const reviewHistory = journal.read()
|
|
@@ -2930,6 +2976,18 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2930
2976
|
return red !== undefined
|
|
2931
2977
|
&& identicalGateFailures(journal.read(), t.id, "test", normalizeGateFailure(String(red.data.details))) < GATE_FINGERPRINT_CAP;
|
|
2932
2978
|
};
|
|
2979
|
+
// C: an unchanged cached test red attributed wholly outside files[] is re-executed ONCE per journaled
|
|
2980
|
+
// subject — the worker cannot repair those files, so a copy of the red would only buy another round.
|
|
2981
|
+
const owesOutsideScopeRefresh = (t, priorRows, commit) => allOutsideScopeReds(t, priorRows, commit)
|
|
2982
|
+
&& !journal.read().some((e) => e.taskId === t.id && e.event === "gate-fresh-forced"
|
|
2983
|
+
&& e.data.reason === OUTSIDE_SCOPE_RED && e.data.commit === commit);
|
|
2984
|
+
// C: the forced re-execution's own round failed again only outside files[]: independent evidence the
|
|
2985
|
+
// worker cannot act on. It keeps its red row and parks gate-fail before any cap, consult or repair move.
|
|
2986
|
+
const freshOutsideScopeRed = (t, commit) => {
|
|
2987
|
+
const own = journal.read().filter((e) => e.taskId === t.id);
|
|
2988
|
+
return allOutsideScopeReds(t, own.slice(own.map((e) => e.event === "phase-start" && e.data.phase === "gates").lastIndexOf(true) + 1), commit);
|
|
2989
|
+
};
|
|
2990
|
+
const outsideScopePark = (t) => gateFailApprovalReason(t.id, "test: a fresh re-execution of the unchanged subject failed again only in files outside files[] — no repair can reach them");
|
|
2933
2991
|
// OBS-1106 residual: before a timeout or wall-budget red accompanied by runner infra diagnostics is
|
|
2934
2992
|
// charged, its attributed failing files are re-run ONCE, on the same checkout, under the suite
|
|
2935
2993
|
// lease. Only a reproduced assertion is charged. A pass, an infra rerun, a rerun naming none of the
|
|
@@ -3297,11 +3355,15 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3297
3355
|
}
|
|
3298
3356
|
// Keep one live list: recovery retries must see exclusions added by onGate during the round.
|
|
3299
3357
|
const badReviewers = [...replayedReviewerExclusions];
|
|
3358
|
+
// C (E1): a checkout-proof timeout is the host being slow, not the seat failing — it is excluded for
|
|
3359
|
+
// the CURRENT round only (so an always-timeout pool still ends the round), never struck task-wide.
|
|
3360
|
+
let roundTimeouts = [];
|
|
3300
3361
|
const noteReviewEvent = (e) => {
|
|
3301
3362
|
journal.append(e.name, t.id, e.payload);
|
|
3302
3363
|
if (e.name === "review-no-verdict" && typeof e.payload.reviewer === "string") {
|
|
3303
|
-
|
|
3304
|
-
|
|
3364
|
+
const strikes = proofTimeoutNote(e.payload) ? roundTimeouts : badReviewers;
|
|
3365
|
+
if (!strikes.includes(e.payload.reviewer))
|
|
3366
|
+
strikes.push(e.payload.reviewer);
|
|
3305
3367
|
}
|
|
3306
3368
|
// OBS-1196: a seat whose same-subject re-emission delivered a valid verdict was a delivery flake,
|
|
3307
3369
|
// not a bad reviewer — it stays seatable for this task's later rounds.
|
|
@@ -3337,17 +3399,31 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3337
3399
|
gateRound: journal.read().filter((row) => row.taskId === task.id
|
|
3338
3400
|
&& row.event === "phase-start" && row.data.phase === "gates").length,
|
|
3339
3401
|
};
|
|
3340
|
-
|
|
3402
|
+
roundTimeouts = [];
|
|
3403
|
+
// C: every gate-round boundary — a returned or a THROWN round — publishes the one row held behind
|
|
3404
|
+
// a still-pending sibling and forgets the pending set, before any park or task-failed row lands.
|
|
3405
|
+
const gateRound = async (subject) => {
|
|
3406
|
+
try {
|
|
3407
|
+
return await runGates(subject, { ...ctx, excludeReviewers: [...badReviewers, ...roundTimeouts] });
|
|
3408
|
+
}
|
|
3409
|
+
finally {
|
|
3410
|
+
settleParallelRound();
|
|
3411
|
+
}
|
|
3412
|
+
};
|
|
3413
|
+
const round = await gateRound(task);
|
|
3341
3414
|
let review = round.results.find((g) => g.gate === "review");
|
|
3415
|
+
// The recovery pick carries every preserved author, exactly as reviewGate's own pick does.
|
|
3416
|
+
const authors = ctx.carriedAuthors?.length ? [...ctx.carriedAuthors] : [channelKey(ctx.author)];
|
|
3342
3417
|
while (review?.meta?.noVerdict === true) {
|
|
3343
3418
|
// Recovery must honor the gate's floor, including the seats that just failed to
|
|
3344
|
-
// return a verdict; a below-floor alternative cannot replace the infra result.
|
|
3345
|
-
|
|
3346
|
-
const
|
|
3419
|
+
// return a verdict; a below-floor alternative cannot replace the infra result. This round's
|
|
3420
|
+
// proof timeouts count too — runGates folds every excluded seat into its own floor.
|
|
3421
|
+
const { floor } = gateReviewerFloor(task, ctx.cfg, ctx.author, ctx.channels, [...(ctx.priorReviewers ?? []), ...badReviewers, ...roundTimeouts]);
|
|
3422
|
+
const next = pickReviewer(ctx.author, ctx.channels, [...badReviewers, ...roundTimeouts], cfg.review.prefer ?? [], floor, reviewHistory, undefined, demotedReviewers, carriedAuthorVendors(ctx.channels, authors), authors);
|
|
3347
3423
|
if (!next)
|
|
3348
3424
|
break;
|
|
3349
3425
|
journal.append("review-infra-retry", t.id, { reviewer: channelKey(next), cause: review.meta.cause });
|
|
3350
|
-
const retried = await
|
|
3426
|
+
const retried = await gateRound({ ...task, gates: ["review"] });
|
|
3351
3427
|
const replacement = retried.results.find((g) => g.gate === "review");
|
|
3352
3428
|
// A dirty tree or another pre-dispatch refusal must still fail closed.
|
|
3353
3429
|
if (!replacement) {
|
|
@@ -3374,13 +3450,16 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3374
3450
|
gate: "review", flaked: rr.flaked, retried: rr.retried,
|
|
3375
3451
|
...(g.meta?.unparseable === true ? { secondUnparseable: true } : {}),
|
|
3376
3452
|
});
|
|
3377
|
-
|
|
3453
|
+
// C (E1): a seat re-routed after a checkout-proof timeout is excluded for this round only.
|
|
3454
|
+
if (!roundTimeouts.includes(rr.flaked))
|
|
3455
|
+
badReviewers.push(rr.flaked);
|
|
3378
3456
|
}
|
|
3379
3457
|
};
|
|
3380
3458
|
// v1.85 T3 (ruling R4): every BLOCKING review/judge result lands its findings in the journal
|
|
3381
3459
|
// structured — class + canonical path + stable symbol — so a retry, a consult or an auto-uphold
|
|
3382
3460
|
// decision reads identity instead of re-parsing prose, and line-number churn is not a new finding.
|
|
3383
3461
|
// One helper, both onGate sites (satisfied-gate resume + main attempt loop).
|
|
3462
|
+
// `journaledGates` narrows replayMeasurement to the gates an interrupted round already journaled (C).
|
|
3384
3463
|
let gateSubject;
|
|
3385
3464
|
const journalGateResult = (g) => {
|
|
3386
3465
|
const blocking = g.meta?.infra !== true && gateFailed(g) && (g.gate === "review" || g.gate === "acceptance");
|
|
@@ -3416,7 +3495,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3416
3495
|
// for the worktree it ran in; absent only for a row that never went through the battery.
|
|
3417
3496
|
...(g.meta?.verification !== undefined ? { verification: g.meta.verification } : {}),
|
|
3418
3497
|
...(gateSubject ? { commit: gateSubject.commit, attempt: gateSubject.attempt } : {}),
|
|
3419
|
-
...(gateSubject?.replayMeasurement ? { replayMeasurement: true } : {}),
|
|
3498
|
+
...(gateSubject?.replayMeasurement && (gateSubject.journaledGates?.includes(g.gate) ?? true) ? { replayMeasurement: true } : {}),
|
|
3420
3499
|
...(gateSubject?.replayedFromAttempt !== undefined ? { replayedFromAttempt: gateSubject.replayedFromAttempt } : {}),
|
|
3421
3500
|
...(g.meta?.skipped === true || noVerdictReview ? { skipped: true } : {}),
|
|
3422
3501
|
// T9: an infra-only exit is journaled AS one. The operator reading a red `test` row has to
|
|
@@ -3530,10 +3609,17 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3530
3609
|
// ever held, and only while an earlier sibling is still in flight — a review that finishes first
|
|
3531
3610
|
// waits for acceptance, never the reverse. That keeps T4's durability where it pays (the first
|
|
3532
3611
|
// verdict to land is still published immediately) and bounds the exposure to one row for the
|
|
3533
|
-
// remainder of one already-running gate. A gate that THROWS
|
|
3534
|
-
// row held behind it
|
|
3612
|
+
// remainder of one already-running gate. A gate that THROWS still ends its round: C publishes the
|
|
3613
|
+
// row held behind it at that boundary (settleParallelRound), so it is never lost or carried over.
|
|
3535
3614
|
const parallelPending = new Set();
|
|
3536
3615
|
let heldParallel;
|
|
3616
|
+
const settleParallelRound = () => {
|
|
3617
|
+
parallelPending.clear();
|
|
3618
|
+
const held = heldParallel;
|
|
3619
|
+
heldParallel = undefined;
|
|
3620
|
+
if (liveEngagement())
|
|
3621
|
+
held?.();
|
|
3622
|
+
};
|
|
3537
3623
|
const notePhaseStart = (e) => {
|
|
3538
3624
|
const active = activeGatePhases.get(t.id) ?? new Set();
|
|
3539
3625
|
active.add(e.gate);
|
|
@@ -4096,6 +4182,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
4096
4182
|
journal.append("gate-provisioned", t.id, provisionedRow);
|
|
4097
4183
|
const resumedTask = { ...t, gates: remainingGates };
|
|
4098
4184
|
const operatorContext = approvalReviewContext(journal.read(), t.id);
|
|
4185
|
+
let outsideScopeForced = false;
|
|
4099
4186
|
gateLoop: while (true) {
|
|
4100
4187
|
fatalStop.signal.throwIfAborted();
|
|
4101
4188
|
executionSignal()?.throwIfAborted();
|
|
@@ -4124,13 +4211,42 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
4124
4211
|
const own = journal.read().filter((e) => e.taskId === t.id);
|
|
4125
4212
|
return own.slice(own.map((e) => e.event === "phase-start" && e.data.phase === "gates").lastIndexOf(true) + 1);
|
|
4126
4213
|
})();
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4214
|
+
// C: a passing selected-test screen is never a forced round's conclusive test result (the
|
|
4215
|
+
// journal's own replay rule): its full suite is still owed, fresh, under the round's bypass.
|
|
4216
|
+
const measured = lastRound.filter((e) => e.event === "gate-result" && !(e.data.gate === "test"
|
|
4217
|
+
&& Array.isArray(e.data.selectedTests) && e.data.fullSuite !== true && e.data.pass !== false));
|
|
4218
|
+
const forcedUnmeasured = (outside) => lastRound.some((e) => e.event === "gate-fresh-forced"
|
|
4219
|
+
&& e.data.commit === gateSubject.commit && (e.data.reason === OUTSIDE_SCOPE_RED) === outside)
|
|
4220
|
+
&& !measured.some((e) => e.data.gate === "test");
|
|
4221
|
+
// C: a crash after the forced re-execution journaled its red but before its park still owes that
|
|
4222
|
+
// park — restored from the journaled subject and result, never re-measured, replayed or repaired.
|
|
4223
|
+
// Infra adjudication stays authoritative, exactly as it is ahead of the live park.
|
|
4224
|
+
if (!recheck && !satisfiedGate && lastRound.some((e) => e.event === "gate-fresh-forced"
|
|
4225
|
+
&& e.data.reason === OUTSIDE_SCOPE_RED && e.data.commit === gateSubject.commit)
|
|
4226
|
+
&& allOutsideScopeReds(t, lastRound, gateSubject.commit)) {
|
|
4227
|
+
const restored = lastRound.filter((e) => e.event === "gate-result" && e.data.gate === "test" && e.data.pass === false)
|
|
4228
|
+
.map((e) => ({ gate: "test", pass: false, details: String(e.data.details ?? ""), meta: e.data }));
|
|
4229
|
+
if (await adjudicateInfraShapedRed(t, restored, wt, gateSubject.commit, rs?.attempts ?? 0, true, (reason) => park(t, reason, "infra", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode)))
|
|
4230
|
+
return;
|
|
4231
|
+
await park(t, outsideScopePark(t), "gate-fail", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
|
|
4232
|
+
return;
|
|
4233
|
+
}
|
|
4234
|
+
// C: the same out-of-scope re-execution the live loop owes, and a crash inside it still owes it. It
|
|
4235
|
+
// takes precedence over a timeout refresh: a qualifying red keeps its own bypass and park.
|
|
4236
|
+
outsideScopeForced = !recheck && !satisfiedGate && ((harvestAuthor !== undefined && owesOutsideScopeRefresh(t, lastRound, gateSubject.commit)) || forcedUnmeasured(true));
|
|
4237
|
+
const freshTimeout = !recheck && !satisfiedGate && !outsideScopeForced && ((harvestAuthor !== undefined && owesTimeoutRefresh(t, lastRound, gateSubject.commit)) || forcedUnmeasured(false));
|
|
4238
|
+
// C: a measurement this forced round EXECUTES fresh — its forced test above all — is independent
|
|
4239
|
+
// evidence, never a replay measurement; only a gate the interrupted round already journaled is.
|
|
4240
|
+
if (outsideScopeForced) {
|
|
4241
|
+
gateSubject = { ...gateSubject, journaledGates: measured.map((e) => String(e.data.gate)) };
|
|
4242
|
+
}
|
|
4130
4243
|
journal.phaseStart(t.id, "gates");
|
|
4131
4244
|
if (freshTimeout) {
|
|
4132
4245
|
journal.append("gate-fresh-forced", t.id, { gate: "test", attempt: gateSubject.attempt, commit: gateSubject.commit, reason: "no-commit-timeout-red", resumed: true });
|
|
4133
4246
|
}
|
|
4247
|
+
if (outsideScopeForced) {
|
|
4248
|
+
journal.append("gate-fresh-forced", t.id, { gate: "test", attempt: gateSubject.attempt, commit: gateSubject.commit, reason: OUTSIDE_SCOPE_RED, resumed: true });
|
|
4249
|
+
}
|
|
4134
4250
|
const { results } = await withCommandContext(t.id, async () => runReviewRecovery(resumedTask, {
|
|
4135
4251
|
carriedAuthors: await subjectAuthors(journal.read(), t.id, wt, taskBase),
|
|
4136
4252
|
producer: producerNow(),
|
|
@@ -4155,7 +4271,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
4155
4271
|
// flaking seat is re-asked on every task.
|
|
4156
4272
|
reviewNoVerdicts,
|
|
4157
4273
|
recheck, // OBS-1055: a recheck discards cached reds — the battery re-measures what the operator questioned
|
|
4158
|
-
cachedRedBypass: freshTimeout ? "timeout-fresh" : undefined,
|
|
4274
|
+
cachedRedBypass: freshTimeout ? "timeout-fresh" : outsideScopeForced ? "out-of-scope-fresh" : undefined,
|
|
4159
4275
|
onGate: async (e) => {
|
|
4160
4276
|
if (!liveEngagement())
|
|
4161
4277
|
return;
|
|
@@ -4220,6 +4336,10 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
4220
4336
|
// assignment back with it.
|
|
4221
4337
|
if (await adjudicateInfraShapedRed(t, results, wt, gateSubject.commit, rs?.attempts ?? 0, false, (reason) => park(t, reason, "infra", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode)))
|
|
4222
4338
|
return;
|
|
4339
|
+
if (outsideScopeForced && freshOutsideScopeRed(t, gateSubject.commit)) {
|
|
4340
|
+
await park(t, outsideScopePark(t), "gate-fail", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
|
|
4341
|
+
return;
|
|
4342
|
+
}
|
|
4223
4343
|
const classified = journal.classifiedDispatch(t.id);
|
|
4224
4344
|
if (await dispositionScopeRed(t, results, classified?.assignment ?? gateAuthor, classified ? (rs?.attempts ?? 0) : Math.max(0, (rs?.attempts ?? 0) - 1), startMs, gateFails, consults, tokens, metered, retryMode, await treeOrDiffPaths(taskBase, wt)))
|
|
4225
4345
|
return;
|
|
@@ -6246,6 +6366,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
6246
6366
|
};
|
|
6247
6367
|
let results = [];
|
|
6248
6368
|
let commits = [];
|
|
6369
|
+
let outsideScopeForced = false;
|
|
6249
6370
|
gateLoop: while (true) {
|
|
6250
6371
|
fatalStop.signal.throwIfAborted();
|
|
6251
6372
|
executionSignal()?.throwIfAborted();
|
|
@@ -6261,12 +6382,18 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
6261
6382
|
&& e.data.attempt === attempt - 1);
|
|
6262
6383
|
const previousRows = retryMode === "repair" ? lastRoundRows : [];
|
|
6263
6384
|
// OBS-1106 residual: see owesTimeoutRefresh — the replay below and the verdict cache are both refused.
|
|
6264
|
-
|
|
6385
|
+
// C: an unchanged red wholly outside files[] is re-executed once instead of copied (journal or cache);
|
|
6386
|
+
// it outranks the timeout refresh, so a timeout-shaped qualifying red keeps its own bypass and park.
|
|
6387
|
+
outsideScopeForced = !explicitlyFundedAttempt && owesOutsideScopeRefresh(t, lastRoundRows, gateSubject.commit);
|
|
6388
|
+
const freshTimeout = !explicitlyFundedAttempt && !outsideScopeForced && owesTimeoutRefresh(t, lastRoundRows, gateSubject.commit);
|
|
6265
6389
|
journal.phaseStart(t.id, "gates");
|
|
6266
6390
|
if (freshTimeout) {
|
|
6267
6391
|
journal.append("gate-fresh-forced", t.id, { gate: "test", attempt, priorAttempt: attempt - 1, commit: gateSubject.commit, reason: "no-commit-timeout-red" });
|
|
6268
6392
|
}
|
|
6269
|
-
|
|
6393
|
+
if (outsideScopeForced) {
|
|
6394
|
+
journal.append("gate-fresh-forced", t.id, { gate: "test", attempt, priorAttempt: attempt - 1, commit: gateSubject.commit, reason: OUTSIDE_SCOPE_RED });
|
|
6395
|
+
}
|
|
6396
|
+
const replay = !explicitlyFundedAttempt && !freshTimeout && !outsideScopeForced && previousRows.length > 0
|
|
6270
6397
|
&& previousRows.every((e) => e.data.commit === gateSubject.commit)
|
|
6271
6398
|
&& previousRows.some((e) => e.data.pass === false && e.data.skipped !== true);
|
|
6272
6399
|
if (replay) {
|
|
@@ -6299,7 +6426,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
6299
6426
|
commands, baseline, channels: pools.review, judgeChannels: pools.judge, health, adapters, cfg, artifactDir: journal.dir,
|
|
6300
6427
|
collateral: collateral.get(t.id) ?? [],
|
|
6301
6428
|
selectTests: !testGateFailed,
|
|
6302
|
-
cachedRedBypass: explicitlyFundedAttempt ? "operator-rerun" : freshTimeout ? "timeout-fresh" : undefined,
|
|
6429
|
+
cachedRedBypass: explicitlyFundedAttempt ? "operator-rerun" : freshTimeout ? "timeout-fresh" : outsideScopeForced ? "out-of-scope-fresh" : undefined,
|
|
6303
6430
|
via: cfg.visibility.llm === "pane"
|
|
6304
6431
|
? {
|
|
6305
6432
|
driver: trackedDriver,
|
|
@@ -6408,6 +6535,10 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
6408
6535
|
}
|
|
6409
6536
|
if (await adjudicateInfraShapedRed(t, results, wt, gateSubject.commit, attempt, gateSubject.replayedFromAttempt !== undefined, (reason) => park(t, reason, "infra", assignment, attempt + 1, startMs, gateFails, consults, tokens, metered, retryMode)))
|
|
6410
6537
|
return;
|
|
6538
|
+
if (outsideScopeForced && freshOutsideScopeRed(t, gateSubject.commit)) {
|
|
6539
|
+
await park(t, outsideScopePark(t), "gate-fail", assignment, attempt + 1, startMs, gateFails, consults, tokens, metered, retryMode);
|
|
6540
|
+
return;
|
|
6541
|
+
}
|
|
6411
6542
|
// OBS-547: who pays for this red is decided by the run's collateral prediction (see
|
|
6412
6543
|
// dispositionScopeRed) — an authoring defect parks unchargeable before any accounting below.
|
|
6413
6544
|
if (await dispositionScopeRed(t, results, assignment, attempt, startMs, gateFails, consults, tokens, metered, retryMode, await treeOrDiffPaths(taskBase, wt)))
|
package/dist/run/git.js
CHANGED
|
@@ -9,6 +9,7 @@ import { StringDecoder } from "node:string_decoder";
|
|
|
9
9
|
import { shq } from "../adapters/types.js";
|
|
10
10
|
import { tickmarkrDir } from "../graph/graph.js";
|
|
11
11
|
import { ROUTING_ENV_SEAMS } from "../route/router.js";
|
|
12
|
+
import { titleEnvironment } from "./title-environment.js";
|
|
12
13
|
export { ROUTING_ENV_SEAMS };
|
|
13
14
|
// OBS-110: gate/baseline/tip-verify children are vitest suites; without a fork cap, concurrent
|
|
14
15
|
// full-suite gate runs fork a worker-per-core pool per worktree and saturate the operator box.
|
|
@@ -324,6 +325,14 @@ function executeShell(cmd, cwd, timeoutMs, login, options, observation) {
|
|
|
324
325
|
// OBS-854: descendants can leave the checkout (nested fixture suites do), so cwd alone cannot
|
|
325
326
|
// attribute them. Every daemon shell exports the daemon pid as their durable parentage marker.
|
|
326
327
|
env[SUITE_PARENT_ENV] = String(process.pid);
|
|
328
|
+
// A2: on Darwin a Node child's title writes stay in JavaScript (title-environment.ts); runnerInputsHash
|
|
329
|
+
// hashes this same effective NODE_OPTIONS, so a verdict is only reused under the identical mode. Native
|
|
330
|
+
// mode is applied too: it drops a preload a nested shell inherited from its parent (unset when that was all).
|
|
331
|
+
const { nodeOptions } = titleEnvironment(env);
|
|
332
|
+
if (nodeOptions === undefined)
|
|
333
|
+
delete env.NODE_OPTIONS;
|
|
334
|
+
else
|
|
335
|
+
env.NODE_OPTIONS = nodeOptions;
|
|
327
336
|
// T7: the capacity every result of this shell carries, read HERE — off the environment the child
|
|
328
337
|
// is about to receive, after the precedence above has settled. An operator export is already in
|
|
329
338
|
// `env`, so what gets recorded is the operator's number, which is the case a release was re-taken
|
package/dist/run/journal.d.ts
CHANGED
|
@@ -650,7 +650,9 @@ export declare const rangePatch: (cwd: string, base: string, head: string) => st
|
|
|
650
650
|
* these and is refused as ambiguous; containment never counts.
|
|
651
651
|
* Both verify (before discharging) and the fold (re-reading every discharge) decide through here.
|
|
652
652
|
*/
|
|
653
|
-
export declare function integrationMapped(cwd: string, events: readonly JournalEvent[], o: OwedCheck, mergeBase: unknown, head: unknown): boolean;
|
|
653
|
+
export declare function integrationMapped(cwd: string, events: readonly JournalEvent[], o: OwedCheck, mergeBase: unknown, head: unknown, memo?: OwedProofMemo): boolean;
|
|
654
|
+
/** I2: a caller-scoped set of completed integration re-proofs (see integrationMapped); absent, every fold re-proves. */
|
|
655
|
+
export type OwedProofMemo = Set<string>;
|
|
654
656
|
/**
|
|
655
657
|
* Lifetime patch authors of base..head, read from the run: the daemon's subjectAuthors fold
|
|
656
658
|
* (daemon.ts, not exported and not this task's to edit) restated synchronously so the waive row is
|
|
@@ -687,6 +689,8 @@ export type ChannelFact = {
|
|
|
687
689
|
* The CURRENT owed-check fold: accepted-risk history, outstanding debt, and whether it is known.
|
|
688
690
|
* `cwd` is any checkout of the run's repository (integration discharges are re-proved from git).
|
|
689
691
|
* It never throws: a fold that cannot be computed is unknown debt, not a crash and never zero.
|
|
692
|
+
* `memo` (I2, optional): a store-scoped table reusing the immutable git re-proof of an integration mapping
|
|
693
|
+
* across folds; every artifact, hash, criteria and reviewer condition is still re-read on every fold.
|
|
690
694
|
*/
|
|
691
|
-
export declare function foldOwedChecks(events: readonly JournalEvent[], cwd: string): OwedFold;
|
|
695
|
+
export declare function foldOwedChecks(events: readonly JournalEvent[], cwd: string, memo?: OwedProofMemo): OwedFold;
|
|
692
696
|
export {};
|
package/dist/run/journal.js
CHANGED
|
@@ -2425,11 +2425,24 @@ const COMMIT_ID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
|
2425
2425
|
* these and is refused as ambiguous; containment never counts.
|
|
2426
2426
|
* Both verify (before discharging) and the fold (re-reading every discharge) decide through here.
|
|
2427
2427
|
*/
|
|
2428
|
-
export function integrationMapped(cwd, events, o, mergeBase, head) {
|
|
2428
|
+
export function integrationMapped(cwd, events, o, mergeBase, head, memo) {
|
|
2429
2429
|
if (typeof mergeBase !== "string" || typeof head !== "string" || !COMMIT_ID.test(mergeBase) || !COMMIT_ID.test(head))
|
|
2430
2430
|
return false;
|
|
2431
2431
|
if (!events.some((e) => e.event === "merge" && e.taskId === o.taskId && e.data.commit === head))
|
|
2432
2432
|
return false;
|
|
2433
|
+
// Only the git component is memoised, and only a completed proof: commit ids name immutable history,
|
|
2434
|
+
// so the key is the obligation's id and recorded range facts plus the claimed mergeBase..head. A refusal
|
|
2435
|
+
// or git failure (a shallow or partial object store can refuse a range it later proves) is never cached
|
|
2436
|
+
// and is re-proved next fold. The merge-row eligibility above is rechecked on every fold.
|
|
2437
|
+
const key = JSON.stringify([o.id, mergeBase, head, o.base, o.head, o.patch, o.patches]);
|
|
2438
|
+
if (memo?.has(key))
|
|
2439
|
+
return true;
|
|
2440
|
+
const proved = integrationProved(cwd, o, mergeBase, head);
|
|
2441
|
+
if (proved)
|
|
2442
|
+
memo?.add(key);
|
|
2443
|
+
return proved;
|
|
2444
|
+
}
|
|
2445
|
+
function integrationProved(cwd, o, mergeBase, head) {
|
|
2433
2446
|
try {
|
|
2434
2447
|
const parents = owedGit(cwd, ["show", "-s", "--format=%P", head]).trim().split(" ");
|
|
2435
2448
|
if (parents.length !== 2 || parents[0] !== mergeBase)
|
|
@@ -2609,7 +2622,7 @@ const declaredVendors = (o) => {
|
|
|
2609
2622
|
return vendors;
|
|
2610
2623
|
};
|
|
2611
2624
|
/** Why a discharge row no longer proves its obligation, re-read from its artifact; undefined when it does. */
|
|
2612
|
-
function dischargeProblem(row, o, events, cwd) {
|
|
2625
|
+
function dischargeProblem(row, o, events, cwd, memo) {
|
|
2613
2626
|
const d = row.data;
|
|
2614
2627
|
if (row.taskId !== o.taskId)
|
|
2615
2628
|
return "discharge names another task";
|
|
@@ -2617,7 +2630,7 @@ function dischargeProblem(row, o, events, cwd) {
|
|
|
2617
2630
|
return "discharge proved other criteria";
|
|
2618
2631
|
const exact = d.mapping === "exact" && d.mergeBase === o.base && d.head === o.head;
|
|
2619
2632
|
// The integration mapping is re-proved from git on every fold, never taken from the row's claims.
|
|
2620
|
-
const integration = d.mapping === "integration" && integrationMapped(cwd, events, o, d.mergeBase, d.head);
|
|
2633
|
+
const integration = d.mapping === "integration" && integrationMapped(cwd, events, o, d.mergeBase, d.head, memo);
|
|
2621
2634
|
if (!exact && !integration)
|
|
2622
2635
|
return "discharge range does not bind the obligation";
|
|
2623
2636
|
let artifact;
|
|
@@ -2675,16 +2688,18 @@ function dischargeProblem(row, o, events, cwd) {
|
|
|
2675
2688
|
* The CURRENT owed-check fold: accepted-risk history, outstanding debt, and whether it is known.
|
|
2676
2689
|
* `cwd` is any checkout of the run's repository (integration discharges are re-proved from git).
|
|
2677
2690
|
* It never throws: a fold that cannot be computed is unknown debt, not a crash and never zero.
|
|
2691
|
+
* `memo` (I2, optional): a store-scoped table reusing the immutable git re-proof of an integration mapping
|
|
2692
|
+
* across folds; every artifact, hash, criteria and reviewer condition is still re-read on every fold.
|
|
2678
2693
|
*/
|
|
2679
|
-
export function foldOwedChecks(events, cwd) {
|
|
2694
|
+
export function foldOwedChecks(events, cwd, memo) {
|
|
2680
2695
|
try {
|
|
2681
|
-
return foldOwedChecksOrThrow(events, cwd);
|
|
2696
|
+
return foldOwedChecksOrThrow(events, cwd, memo);
|
|
2682
2697
|
}
|
|
2683
2698
|
catch (error) {
|
|
2684
2699
|
return { known: false, debt: "unknown", outstanding: [], acceptedRisk: [], discharged: [], unknown: [{ reason: `owed-check fold failed: ${String(error).split("\n")[0]}` }] };
|
|
2685
2700
|
}
|
|
2686
2701
|
}
|
|
2687
|
-
function foldOwedChecksOrThrow(events, cwd) {
|
|
2702
|
+
function foldOwedChecksOrThrow(events, cwd, memo) {
|
|
2688
2703
|
const accepted = new Map();
|
|
2689
2704
|
const discharged = new Set();
|
|
2690
2705
|
const unknown = [];
|
|
@@ -2703,7 +2718,7 @@ function foldOwedChecksOrThrow(events, cwd) {
|
|
|
2703
2718
|
const ids = Array.isArray(e.data.ids) ? e.data.ids : [];
|
|
2704
2719
|
const problems = ids.map((id) => {
|
|
2705
2720
|
const o = accepted.get(String(id));
|
|
2706
|
-
return o ? dischargeProblem(e, o, events, cwd) : "discharge names no recorded obligation";
|
|
2721
|
+
return o ? dischargeProblem(e, o, events, cwd, memo) : "discharge names no recorded obligation";
|
|
2707
2722
|
});
|
|
2708
2723
|
const problem = ids.length ? problems.find((p) => p !== undefined) : "discharge names no obligation";
|
|
2709
2724
|
if (problem)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { AuthHealth, LaunchServicesRecord } from "../adapters/types.js";
|
|
3
|
+
/**
|
|
4
|
+
* v2.6.5 T10 (A1, D-787/D-789): did launchservicesd restart since tickmarkr last looked?
|
|
5
|
+
*
|
|
6
|
+
* On 2026-09-30 a heavy local suite exhausted launchservicesd's Mach port space (0x1000600b); the daemon
|
|
7
|
+
* crashed, respawned with an empty app registry, and every GUI app launched before it read "not open
|
|
8
|
+
* anymore". The tell is the daemon's own identity: a young pid/start beside an old Dock and Finder.
|
|
9
|
+
* doctor and standalone verify read that identity with one bounded /bin/ps call, compare it with the
|
|
10
|
+
* one the FIRST doctor.json record holds (registry order — no new state file or adapter row), and
|
|
11
|
+
* persist one advisory on a change. Unreadable evidence is unknown: the prior identity is kept, never
|
|
12
|
+
* replaced by a guess. Advisory only — verdicts, retries and routing never read it.
|
|
13
|
+
*/
|
|
14
|
+
export declare const LAUNCHSERVICES_PROBE_CEILING_MS = 5000;
|
|
15
|
+
export interface LaunchServicesIdentity {
|
|
16
|
+
pid: number;
|
|
17
|
+
start: string;
|
|
18
|
+
}
|
|
19
|
+
export type LaunchServicesObservation = {
|
|
20
|
+
status: "non-darwin";
|
|
21
|
+
} | {
|
|
22
|
+
status: "read";
|
|
23
|
+
identity: LaunchServicesIdentity;
|
|
24
|
+
} | {
|
|
25
|
+
status: "unreadable";
|
|
26
|
+
detail: string;
|
|
27
|
+
};
|
|
28
|
+
/** The closed identity table; "first" is the baseline row when no prior identity is held. */
|
|
29
|
+
export type LaunchServicesStatus = "first" | "unchanged" | "changed-pid" | "changed-start" | "unreadable" | "non-darwin" | "skipped";
|
|
30
|
+
/** Exactly one launchservicesd row, or null (absent, ambiguous or malformed output is unknown). */
|
|
31
|
+
export declare function parsePsIdentity(stdout: string): LaunchServicesIdentity | null;
|
|
32
|
+
export type ProbeTimer = (fire: () => void, ms: number) => () => void;
|
|
33
|
+
interface ProbeSeam {
|
|
34
|
+
platform: NodeJS.Platform;
|
|
35
|
+
spawn: typeof spawn;
|
|
36
|
+
setTimer: ProbeTimer;
|
|
37
|
+
signal?: AbortSignal;
|
|
38
|
+
}
|
|
39
|
+
export declare const setLaunchServicesProbeForTests: (s: Partial<ProbeSeam>) => void;
|
|
40
|
+
export declare const resetLaunchServicesProbeForTests: () => void;
|
|
41
|
+
export declare function observeLaunchServices(s?: ProbeSeam): Promise<LaunchServicesObservation>;
|
|
42
|
+
export declare function launchServicesAdvisory(prior: LaunchServicesIdentity, now: LaunchServicesIdentity): string;
|
|
43
|
+
/** Pure: the table row for one observation against the held record, and the record to hold next. */
|
|
44
|
+
export declare function classifyLaunchServices(prior: Partial<LaunchServicesRecord> | undefined, observed: LaunchServicesObservation): {
|
|
45
|
+
status: LaunchServicesStatus;
|
|
46
|
+
next?: LaunchServicesRecord;
|
|
47
|
+
line?: string;
|
|
48
|
+
};
|
|
49
|
+
/** Observe, classify against the first record of `prior`, and hold the result on the first record of `health`.
|
|
50
|
+
* Returns `health` itself when nothing changed, else a copy — the argument is never mutated. */
|
|
51
|
+
export declare function recordLaunchServices(prior: Record<string, AuthHealth> | null, health: Record<string, AuthHealth>): Promise<{
|
|
52
|
+
status: LaunchServicesStatus;
|
|
53
|
+
health: Record<string, AuthHealth>;
|
|
54
|
+
line?: string;
|
|
55
|
+
}>;
|
|
56
|
+
export {};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* v2.6.5 T10 (A1, D-787/D-789): did launchservicesd restart since tickmarkr last looked?
|
|
5
|
+
*
|
|
6
|
+
* On 2026-09-30 a heavy local suite exhausted launchservicesd's Mach port space (0x1000600b); the daemon
|
|
7
|
+
* crashed, respawned with an empty app registry, and every GUI app launched before it read "not open
|
|
8
|
+
* anymore". The tell is the daemon's own identity: a young pid/start beside an old Dock and Finder.
|
|
9
|
+
* doctor and standalone verify read that identity with one bounded /bin/ps call, compare it with the
|
|
10
|
+
* one the FIRST doctor.json record holds (registry order — no new state file or adapter row), and
|
|
11
|
+
* persist one advisory on a change. Unreadable evidence is unknown: the prior identity is kept, never
|
|
12
|
+
* replaced by a guess. Advisory only — verdicts, retries and routing never read it.
|
|
13
|
+
*/
|
|
14
|
+
export const LAUNCHSERVICES_PROBE_CEILING_MS = 5_000;
|
|
15
|
+
// lstart in the C locale: "Wed Sep 30 21:54:33 2026"; comm is the executable path on Darwin.
|
|
16
|
+
const PS_ARGS = ["-A", "-o", "pid=,lstart=,comm="];
|
|
17
|
+
const PS_LINE = /^\s*(\d+)\s+(\w{3}\s+\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.+?)\s*$/;
|
|
18
|
+
const MAX_PS_BYTES = 4 * 1024 * 1024;
|
|
19
|
+
/** Exactly one launchservicesd row, or null (absent, ambiguous or malformed output is unknown). */
|
|
20
|
+
export function parsePsIdentity(stdout) {
|
|
21
|
+
const rows = stdout.split("\n").flatMap((line) => {
|
|
22
|
+
const m = PS_LINE.exec(line);
|
|
23
|
+
return m && basename(m[3]) === "launchservicesd" ? [{ pid: Number(m[1]), start: m[2].replace(/\s+/g, " ") }] : [];
|
|
24
|
+
});
|
|
25
|
+
return rows.length === 1 ? rows[0] : null;
|
|
26
|
+
}
|
|
27
|
+
const production = () => ({
|
|
28
|
+
platform: process.platform,
|
|
29
|
+
spawn,
|
|
30
|
+
setTimer: (fire, ms) => { const t = setTimeout(fire, ms); return () => clearTimeout(t); },
|
|
31
|
+
});
|
|
32
|
+
// ponytail: default-off under the test runner (the catalogRefreshAllowed precedent) so an unowned doctor
|
|
33
|
+
// test never takes a real host reading or changes its doctor.json bytes; tests opt in through the seam.
|
|
34
|
+
let seam;
|
|
35
|
+
export const setLaunchServicesProbeForTests = (s) => { seam = { ...production(), ...s }; };
|
|
36
|
+
export const resetLaunchServicesProbeForTests = () => { seam = undefined; };
|
|
37
|
+
const activeSeam = () => seam ?? (process.env.VITEST === "true" ? undefined : production());
|
|
38
|
+
/** One /bin/ps call that owns only its child, killed at the ceiling or on cancellation and reaped before it resolves. */
|
|
39
|
+
function readIdentity(s) {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
const unknown = (detail) => resolve({ status: "unreadable", detail });
|
|
42
|
+
if (s.signal?.aborted) {
|
|
43
|
+
unknown("probe cancelled");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
let child;
|
|
47
|
+
try {
|
|
48
|
+
child = s.spawn("/bin/ps", PS_ARGS, { stdio: ["ignore", "pipe", "ignore"], env: { ...process.env, LC_ALL: "C" } });
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
unknown(`/bin/ps did not start (${e instanceof Error ? e.message : String(e)})`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
let out = "";
|
|
55
|
+
let why;
|
|
56
|
+
const retire = (reason) => { why ??= reason; child.kill("SIGKILL"); };
|
|
57
|
+
const clear = s.setTimer(() => retire(`/bin/ps exceeded the ${LAUNCHSERVICES_PROBE_CEILING_MS} ms kill ceiling`), LAUNCHSERVICES_PROBE_CEILING_MS);
|
|
58
|
+
const cancel = () => retire("probe cancelled");
|
|
59
|
+
s.signal?.addEventListener("abort", cancel, { once: true });
|
|
60
|
+
child.stdout?.on("data", (chunk) => {
|
|
61
|
+
out += chunk.toString("utf8");
|
|
62
|
+
if (out.length > MAX_PS_BYTES)
|
|
63
|
+
retire("/bin/ps output exceeded its bound");
|
|
64
|
+
});
|
|
65
|
+
child.once("error", (e) => { why ??= `/bin/ps failed (${e.message})`; });
|
|
66
|
+
child.once("close", (code) => {
|
|
67
|
+
clear();
|
|
68
|
+
s.signal?.removeEventListener("abort", cancel);
|
|
69
|
+
if (why)
|
|
70
|
+
return unknown(why);
|
|
71
|
+
if (code !== 0)
|
|
72
|
+
return unknown(`/bin/ps exited ${code}`);
|
|
73
|
+
const identity = parsePsIdentity(out);
|
|
74
|
+
return identity ? resolve({ status: "read", identity }) : unknown("no single launchservicesd row in /bin/ps output");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
export function observeLaunchServices(s = production()) {
|
|
79
|
+
return s.platform === "darwin" ? readIdentity(s) : Promise.resolve({ status: "non-darwin" });
|
|
80
|
+
}
|
|
81
|
+
// doctor.json is never schema-validated: a malformed held record is no identity at all.
|
|
82
|
+
const knownPrior = (prior) => typeof prior?.pid === "number" && typeof prior.start === "string";
|
|
83
|
+
export function launchServicesAdvisory(prior, now) {
|
|
84
|
+
return `launchservicesd restarted: pid ${prior.pid} → ${now.pid}, started ${prior.start} → ${now.start}. `
|
|
85
|
+
+ `GUI apps launched before ${now.start} may read "not open anymore" (D-787: the daemon crashed and respawned with an empty app registry). `
|
|
86
|
+
+ `Remedy: quit and relaunch each affected app, or log out / restart the Mac (a restart ends agent sessions and wipes /private/tmp); `
|
|
87
|
+
+ `confirm with /usr/bin/log show (launchservicesd 0x1000600b near ${now.start}) and ~/Library/Logs/DiagnosticReports/ExcUserFault_node-*.ips (advisory — verdicts unchanged)`;
|
|
88
|
+
}
|
|
89
|
+
/** Pure: the table row for one observation against the held record, and the record to hold next. */
|
|
90
|
+
export function classifyLaunchServices(prior, observed) {
|
|
91
|
+
const kept = knownPrior(prior) ? prior : undefined;
|
|
92
|
+
if (observed.status === "non-darwin")
|
|
93
|
+
return { status: "non-darwin", next: kept };
|
|
94
|
+
if (observed.status === "unreadable") {
|
|
95
|
+
const held = kept ? `pid ${kept.pid} started ${kept.start}` : "none";
|
|
96
|
+
return { status: "unreadable", next: kept, line: `launchservicesd identity unknown — ${observed.detail}; prior identity (${held}) retained (advisory — verdicts unchanged)` };
|
|
97
|
+
}
|
|
98
|
+
const now = observed.identity;
|
|
99
|
+
if (!knownPrior(prior))
|
|
100
|
+
return { status: "first", next: now };
|
|
101
|
+
const status = prior.pid !== now.pid ? "changed-pid" : prior.start !== now.start ? "changed-start" : "unchanged";
|
|
102
|
+
if (status === "unchanged")
|
|
103
|
+
return { status, next: prior };
|
|
104
|
+
const advisory = launchServicesAdvisory(prior, now);
|
|
105
|
+
return { status, next: { ...now, advisory }, line: advisory };
|
|
106
|
+
}
|
|
107
|
+
/** Observe, classify against the first record of `prior`, and hold the result on the first record of `health`.
|
|
108
|
+
* Returns `health` itself when nothing changed, else a copy — the argument is never mutated. */
|
|
109
|
+
export async function recordLaunchServices(prior, health) {
|
|
110
|
+
const held = prior ? Object.values(prior)[0]?.launchServices : undefined;
|
|
111
|
+
const s = activeSeam();
|
|
112
|
+
const r = s ? classifyLaunchServices(held, await observeLaunchServices(s)) : { status: "skipped", next: knownPrior(held) ? held : undefined };
|
|
113
|
+
const holder = Object.keys(health)[0];
|
|
114
|
+
if (!holder || !r.next || JSON.stringify(health[holder].launchServices) === JSON.stringify(r.next))
|
|
115
|
+
return { ...r, health };
|
|
116
|
+
return { ...r, health: { ...health, [holder]: { ...health[holder], launchServices: r.next } } };
|
|
117
|
+
}
|