Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/portos-file-issue/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@ description: PortOS conventions for filing GitHub issues and capturing deferred
- `help wanted` — extra hands welcome and the body is scoped enough to pick up cold.
- **Planner attribution (`planner:<model>`) records who WROTE the plan.** It is a third independent axis — the author, not a recommendation about how to run the work. Take the label verbatim from the "Planner Attribution" section of your own run's prompt (PortOS resolves it from the provider/model it dispatched you with); **never guess it from what you believe you are**, and omit the axis entirely when your run was given none. Create it like any other label: `gh label create planner:<model> --color C2185B --description 'Plan authored by the <model> model' 2>/dev/null || true`.
Omit an axis rather than guessing. Do not stamp `medium` on both dispatch axes. Do not put `[model:…]` / `[effort:…]` / `[category]` / `[SEVERITY]` in the title. Create each missing label immediately before applying it (`gh label create <name> --color <hex> 2>/dev/null || true`; colors: model:light `D4C5F9`, model:medium `A371F7`, model:heavy `6F42C1`, effort:low `BFE5E5`, effort:medium `76C7C7`, effort:high `1D7874`, effort:xhigh `0E4F4C`, effort:max `05403D`, good first issue `7057FF`, help wanted `008672`). Use repeated `--label` flags. Never relabel a deduplicated existing issue.
- **`future` is almost never correct.** Do NOT use it to park a choice you could make yourself. The narrow legitimate park is work a **human must personally drive** — a real fidelity/validation run only they can judge, or a step that needs **specific hardware/credentials** the agent doesn't have (e.g. a CUDA box, an Apple-Silicon-only validation). Mark those `blocked` (skipped by autonomous claim runs), not `future`, and say in the body exactly what human action unblocks it. `blocked` also covers a genuine dependency on another unshipped issue/PR.
- **`future` is almost never correct.** Do NOT use it to park a choice you could make yourself. The narrow legitimate park is work a **human must personally drive** — a real fidelity/validation run only they can judge, or a step that needs **specific hardware/credentials** the agent doesn't have (e.g. a CUDA box, an Apple-Silicon-only validation). Mark those `blocked` (skipped by autonomous claim runs), not `future`, and say in the body exactly what human action unblocks it. `blocked` also covers a genuine dependency on another unshipped issue/PR — for that case, name the dependency on its own line as `Blocked by #N` (or `Blocked by #N, #M` for more than one): the `issue-reconcile` scheduled task (`blockedIssueReconcile.js`) parses exactly that line, and removes the label automatically once every named issue is closed. An issue with no `Blocked by` line is never auto-unblocked, so the human/hardware case is unaffected.
- **`needs-input` is a last resort, not a reflex.** When claiming an ambiguous issue, prefer proceeding on your best interpretation and shipping it. Fall back to `needs-input` only when proceeding would be **destructive/irreversible** or genuinely requires the human (hardware, credentials, a personal judgment call) — never merely because more than one reasonable reading exists.
1 change: 1 addition & 0 deletions server/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub
| `createKeyCachedQueue.js` | Per-KEY serialized async work queue (sibling to `fileWriteQueue.js`'s single tail). `createKeyCachedQueue()` returns `queue(key, work)` that chains each `work` thunk onto the prior in-flight promise for that `key` — same-key work runs one-after-another (later sees earlier's committed result), different keys run concurrently. Self-pruning tail Map; `work` runs on both fulfil and reject so one failure can't stall the chain; carries `.clear()` for test reset. Used by the media-job completion hooks (writers-room / catalog / music-video scene-image attach) to serialize per-record. |
| `createNewestWinsGuard.js` | Newest-render-wins ordering guard for out-of-order async completions. `createNewestWinsGuard()` returns `{ isStale(key, at), mark(key, at), clear() }` — tracks the newest applied `queuedAt` per slot `key` so an older render completing after a newer one is dropped (`isStale` true) instead of clobbering the newer frame. ISO timestamps compare as strings; absent `at` is never stale. Used by `createMediaJobImageHook`'s opt-in guard and the catalog hook's portrait slot. |
| `fileWriteQueue.js` | Serialize read-modify-write cycles so they don't interleave. `createFileWriteQueue()` → single-tail `queue(fn)` for one shared file; `createRecordWriteQueue(assertId?)` → id-keyed `queueRecordWrite(id, fn)` where same-id cycles serialize and different-id cycles run in parallel (the queue the PG/file store facades use for `collectionStore.queueRecordWrite` parity); `createKeyedFileWriteQueue()` → `queueKeyedWrite(key, fn)` that collapses the pipeline stage stores' hand-rolled `Map` + per-key `createFileWriteQueue` factory (falsy keys → shared `'__unknown__'` tail; self-pruning via `createKeyCachedQueue`). |
| `forgeIssueState.js` | `normalizeIssueState(state)` — collapse a forge issue/PR/MR state string to `open`/`closed`. GitHub reports `OPEN`/`CLOSED`; GitLab reports `opened`/`closed`/`locked`; anything unrecognized is treated as open so an unfamiliar state can never read as "already resolved". Shared by `layeredIntelligence/forgeFiler.js` and `services/blockedIssueReconcile.js`. |
| `imageClean.js` | `cleanImageBuffer(buf, { metadata, denoise })` (composable opt-in pipeline: lossless metadata/C2PA strip + optional median/sharpen denoise) · `stripPngMetadataChunks` / `stripPngC2PAChunk` (lossless PNG-chunk removers) · `compositeIgnoreZone(base, original, mask, { feather })` (preserve-region compositing: restore original pixels into a feathered mask over a diffused result) · `autoCleanGeneratedImage` (in-place clean for post-generation hook). HTTP route in `routes/imageClean.js` wraps `cleanImageBuffer` and appends a CPU light diffusion pass (`applyLightRegen` from `services/imageGen/regen.js`) for the `diffusion=light` SynthID-disruption step. |
| `imageFrameStats.js` | Degenerate-frame classifier (#4173) — `describeFrameStats(bufferOrPath)` runs one `sharp` `.stats()` decode and returns `{ ok, reason, perChannel }`, rejecting `solid-fill` (every colour channel stdev under `SOLID_FILL_STDEV_EPSILON`), `fully-transparent` (alpha max 0) and `near-empty` (greyscale entropy under `NEAR_EMPTY_ENTROPY_FLOOR`), except when a non-opaque alpha channel carries a substantial silhouette itself. Deliberately NOT a quality judge: a legitimately dark or minimalist render keeps real per-channel variance and is accepted. `ok` is three-valued — `true`/`false`/`null`, where `null` means could-not-measure (undecodable buffer, or under `MIN_JUDGEABLE_PIXELS`) and must never read as degenerate; gate on `isDegenerateFrame(stats)` / `ok === false`, never `!ok`. Called by the Image Gen provider completion seams, sprite reference normalization, and `visionTest.js` (so a paid vision call is never spent on a blank frame). A **buffer** verdict is memoized on the sha256 of its bytes (#6004) — the probe costs ~4ms regardless of pixel count and the sprite compiler re-probes identical frames dozens of times per run; a **path** is never memoized (the file behind it can change), so hand it bytes you already read. `stats-unavailable` is never memoized either — that branch catches transient probe failures too, and pinning `ok: null` on valid bytes would disable the gate for them. `__resetFrameStatsCache()` drops the memo in tests. |
| `imageRgba.js` | Sharp-backed RGBA boundary: `decodeRgbaFrame` decodes an image to `{ data, width, height }`, and `encodePng` turns a raw frame into PNG bytes for the caller's destination/hash policy. |
Expand Down
20 changes: 20 additions & 0 deletions server/lib/forgeIssueState.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Normalize a forge issue/PR/MR state string to `open` / `closed`. GitLab
* reports `opened` (and `closed`/`locked`); GitHub reports `open`/`closed`.
* Anything unrecognized is treated as open, so an unfamiliar state string
* never reads as "already resolved" and short-circuits a scan that depends on
* catching every still-open item (dedup/park/blocker checks). (`merged` never
* applies to issues, only PRs/MRs.)
*
* Shared by `layeredIntelligence/forgeFiler.js` and `blockedIssueReconcile.js`
* — lifted here (rather than duplicated, or imported cross-subsystem from
* `layeredIntelligence/`) per the Import scoping convention in
* `server/AGENTS.md`.
* @param {string} state
* @returns {'open'|'closed'}
*/
export function normalizeIssueState(state) {
const s = (state || '').toLowerCase();
if (s === 'closed' || s === 'locked') return 'closed';
return 'open';
}
1 change: 1 addition & 0 deletions server/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ export * from './agentInstructionsFile.js';
export * from './fileCore.js';
export * as fileUtils from './fileUtils.js';
export * from './fileWriteQueue.js';
export * from './forgeIssueState.js';
export * from './portosEnv.js';
export * from './homePath.js';
export * from './jsonIo.js';
Expand Down
230 changes: 230 additions & 0 deletions server/services/blockedIssueReconcile.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
/**
* Blocked-Issue Reconciler — deterministic core.
*
* The `blocked` label (portos-file-issue skill) covers two different reasons an
* issue is parked: a step only a human can drive (hardware/credentials), or "a
* genuine dependency on another unshipped issue/PR." Nothing removes the label
* once the second case resolves — the claim queue (`NON_ACTIONABLE_ISSUE_LABELS`
* in perpetualWork.js) skips every `blocked` issue regardless of *why* it is
* blocked, so a dependency-blocked issue sits parked forever even after its
* blocker ships.
*
* This scan finds open `blocked` issues whose body names its blocker(s) via a
* `Blocked by #N[, #M ...]` line, checks whether every named blocker is closed,
* and — only when ALL of them are — removes the `blocked` label and posts a
* comment. An issue with no parsed blocker reference is left untouched: that is
* the human/hardware case, and this scan draws no inference from silence.
*
* Forge-agnostic like issueReconcile.js: GitHub via `gh`, GitLab via `glab`,
* resolved from the git origin host through `resolveRepoForgeTarget` /
* `resolveAppForgeTarget`. The decision needs no judgment (a blocker is either
* closed or it isn't), so — mirroring `releaseAbandonedClaims` in
* issueReconcile.js — this is a pure deterministic write with no coordinator
* agent and no LLM call.
*/

import { execGh, ensureForgeReachable } from './github.js';
import { execGlab, execGlabJson } from './gitlab.js';
import { resolveAppForgeTarget, resolveRepoForgeTarget } from '../lib/workTracker.js';
import { safeJSONParse } from '../lib/fileUtils.js';
import { normalizeIssueState } from '../lib/forgeIssueState.js';

export const BLOCKED_LABEL = 'blocked';

// Bound the forge queries — single-user repos never realistically truncate at
// this size (mirrors issueReconcile.js's GH_LIST_LIMIT / GL_PER_PAGE).
const GH_LIST_LIMIT = 100;
const GH_ALL_STATE_LIMIT = 300;
const GL_PER_PAGE = 100;

/**
* Parse the issue numbers named by a `Blocked by #N[, #M, ...]` line (an
* optional leading bullet/dash and an optional trailing colon are tolerated),
* case-insensitive. Returns a sorted, deduped array of positive integers, or
* `[]` when the body names no blocker — that empty result is the signal this
* scan leaves the issue alone; a human/hardware block has no dependency to
* resolve, so absence of the trailer must never be read as "ready to unblock."
*
* Deliberately narrow: only text on a `Blocked by` line counts, not any `#N`
* mention anywhere in the body — an ordinary cross-reference, `Related: #N`, or
* `Refs #N` elsewhere in the issue must never be treated as a dependency this
* scan can clear.
* @param {string} body
* @returns {number[]}
*/
export function parseBlockingIssueNumbers(body) {
if (!body) return [];
const nums = new Set();
const lineRe = /^[ \t*-]*Blocked by:?\s*(.*)/gim;
let line;
while ((line = lineRe.exec(body))) {
const numRe = /#(\d+)/g;
let n;
while ((n = numRe.exec(line[1]))) nums.add(Number(n[1]));
}
return [...nums].sort((a, b) => a - b);
}

/**
* Fetch GitHub's open `blocked` issues plus a `number → state` map covering
* every issue (open + closed, bounded by GH_ALL_STATE_LIMIT) so each blocker
* reference can be resolved from one extra call rather than one `gh issue view`
* per blocker. Returns null on any gh failure — the caller treats null as
* "skip this cycle", never as "no blocked issues" or "every blocker is open".
* @returns {Promise<{ blocked: object[], stateByNumber: Map<number,string> }|null>}
*/
async function getGithubBlockedState(repoSpec, apiHost) {
const forge = await ensureForgeReachable('blocked-issue-reconcile', { hostname: apiHost });
if (!forge.ok) return null;

const ghList = (args, what) => execGh(args).catch((err) => {
console.error(`❌ blocked-issue-reconcile: ${what} failed for ${repoSpec}: ${err.message}`);
return null;
});

const [blockedRaw, allRaw] = await Promise.all([
ghList(['issue', 'list', '--repo', repoSpec, '--state', 'open',
'--label', BLOCKED_LABEL, '--limit', String(GH_LIST_LIMIT),
'--json', 'number,title,body,url'], 'gh issue list --label blocked'),
ghList(['issue', 'list', '--repo', repoSpec, '--state', 'all',
'--limit', String(GH_ALL_STATE_LIMIT), '--json', 'number,state'], 'gh issue list --state all'),
]);

const blocked = safeJSONParse(blockedRaw, null);
if (!Array.isArray(blocked)) return null;
// The blocked list can legitimately be empty (nothing to unblock) — a caller
// reads that as "no candidates", not "gh failed", because it parsed fine.
const all = safeJSONParse(allRaw, null);
if (!Array.isArray(all)) return null;

const stateByNumber = new Map();
for (const issue of all) {
if (Number.isInteger(issue?.number)) stateByNumber.set(issue.number, normalizeIssueState(issue.state));
}
return {
blocked: blocked.map((i) => ({ number: i.number, title: i.title || '', url: i.url || '', body: i.body || '' })),
stateByNumber,
};
}

/**
* GitLab mirror of `getGithubBlockedState`. `glab issue list --all` returns
* every issue's `iid` + `state` in one call, avoiding a per-blocker view call.
* @returns {Promise<{ blocked: object[], stateByNumber: Map<number,string> }|null>}
*/
async function getGitlabBlockedState(repoPath) {
const [blocked, all] = await Promise.all([
execGlabJson(['issue', 'list', '--label', BLOCKED_LABEL, '--per-page', String(GL_PER_PAGE)], repoPath),
execGlabJson(['issue', 'list', '--all', '--per-page', String(GL_PER_PAGE)], repoPath),
]);
if (!blocked.rows || !all.rows) {
console.error(`❌ blocked-issue-reconcile: glab issue list unavailable (${blocked.reason}/${all.reason}) — skipping this cycle`);
return null;
}
const stateByNumber = new Map();
for (const issue of all.rows) {
if (Number.isInteger(issue?.iid)) stateByNumber.set(issue.iid, normalizeIssueState(issue.state));
}
return {
blocked: blocked.rows.map((i) => ({
number: i.iid, title: i.title || '', url: i.web_url || '', body: i.description || '',
})),
stateByNumber,
};
}

/**
* Gather + classify: for every open `blocked` issue that names blocker(s) via
* the `Blocked by #N` convention, check whether all of them are closed. Pure
* classification over already-fetched state.
* @param {object[]} blocked
* @param {Map<number,string>} stateByNumber
* @returns {object[]} entries with `blockingNumbers`, `closedBlockers`, `openBlockers` — only issues with at least one parsed blocker are included
*/
export function classifyBlockedIssues(blocked, stateByNumber) {
return blocked
.map((issue) => ({ ...issue, blockingNumbers: parseBlockingIssueNumbers(issue.body) }))
.filter((issue) => issue.blockingNumbers.length > 0)
.map((issue) => {
const closedBlockers = issue.blockingNumbers.filter((n) => stateByNumber.get(n) === 'closed');
// A blocker whose state could not be resolved (absent from the map) must
// NOT read as closed — it stays in openBlockers, same absent-vs-false
// discipline as issueReconcile's hasForeignClaim.
const openBlockers = issue.blockingNumbers.filter((n) => stateByNumber.get(n) !== 'closed');
return { number: issue.number, title: issue.title, url: issue.url, blockingNumbers: issue.blockingNumbers, closedBlockers, openBlockers };
});
}

/**
* Full gather + classify for one app's forge repo. Returns `{ forge, repoSpec,
* fullName, ready }` where `ready` is every dependency-blocked issue whose
* blockers are ALL closed — or null on an unsupported remote / transient
* failure, so the caller skips this cycle without treating it as "nothing to
* unblock".
* @param {string} repoPath
* @param {{ app?: object }} [opts]
*/
export async function gatherBlockedIssueState(repoPath, { app = null } = {}) {
const target = app
? (await resolveAppForgeTarget(app, { repoPath })).target
: await resolveRepoForgeTarget(repoPath);
if (!target) return null;

let state = null;
if (target.forge === 'github') state = await getGithubBlockedState(target.repoSpec, target.apiHost);
else if (target.forge === 'gitlab') state = await getGitlabBlockedState(repoPath);
if (!state) return null;

return {
forge: target.forge,
repoSpec: target.repoSpec ?? null,
fullName: target.fullName,
ready: classifyBlockedIssues(state.blocked, state.stateByNumber).filter((i) => i.openBlockers.length === 0),
};
}

/**
* Remove the `blocked` label (and post an explanatory comment first) on every
* issue whose dependency is now fully resolved. The comment is posted BEFORE
* the label removal so an unblock is never silent, mirroring
* `releaseAbandonedClaims`; a failed comment does not block the removal.
* @param {object[]} ready - from `gatherBlockedIssueState().ready`
* @param {{ forge:string, repoSpec:string|null, fullName:string, repoPath:string }} ctx
* @returns {Promise<number>} how many issues were actually unblocked
*/
export async function unblockIssues(ready, { forge, repoSpec, fullName, repoPath }) {
if (!ready?.length) return 0;
if (forge !== 'github' && forge !== 'gitlab') return 0;
if (forge === 'github' && !repoSpec) return 0;

let unblocked = 0;
for (const issue of ready) {
const number = String(issue.number);
const blockersList = issue.closedBlockers.map((n) => `#${n}`).join(', ');
const commentBody = `Unblocking: every issue named in \`Blocked by\` (${blockersList}) is now closed. Removing the \`${BLOCKED_LABEL}\` label so this rejoins the claim queue.`;

let ok;
if (forge === 'github') {
await execGh(['issue', 'comment', number, '--repo', repoSpec, '--body', commentBody]).catch((err) => {
console.error(`❌ blocked-issue-reconcile: could not comment on #${number} in ${fullName}: ${err.message}`);
});
ok = await execGh(['issue', 'edit', number, '--repo', repoSpec, '--remove-label', BLOCKED_LABEL])
.then(() => true)
.catch((err) => {
console.error(`❌ blocked-issue-reconcile: could not unblock #${number} in ${fullName}: ${err.message}`);
return false;
});
} else {
const noted = await execGlab(['issue', 'note', number, '--message', commentBody], repoPath);
if (noted === null) console.error(`❌ blocked-issue-reconcile: could not comment on #${number} in ${fullName}`);
const result = await execGlab(['issue', 'update', number, '--unlabel', BLOCKED_LABEL], repoPath);
ok = result !== null;
if (!ok) console.error(`❌ blocked-issue-reconcile: could not unblock #${number} in ${fullName}`);
}
if (ok) {
unblocked += 1;
console.log(`🔓 blocked-issue-reconcile unblocked #${number} in ${fullName}: ${blockersList} closed`);
}
}
return unblocked;
}
Loading