diff --git a/packages/software-factory/realm/test-results.gts b/packages/software-factory/realm/test-results.gts
index 4e05ca8ad6..ec0e1c7acc 100644
--- a/packages/software-factory/realm/test-results.gts
+++ b/packages/software-factory/realm/test-results.gts
@@ -234,6 +234,9 @@ export class TestRun extends CardDef {
#{{@model.sequenceNumber}}
{{@model.status}}
+ {{#if @model.issue}}
+
{{@model.issue.summary}}
+ {{/if}}
{{@model.passedCount}}/{{this.total}}
passed
@@ -281,6 +284,13 @@ export class TestRun extends CardDef {
color: var(--boxel-blue, #2563eb);
background: #eff6ff;
}
+ .issue-name {
+ font-size: 0.8rem;
+ color: var(--muted-foreground);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
.counts {
font-size: 0.85rem;
color: var(--muted-foreground);
diff --git a/packages/software-factory/scripts/smoke-tests/issue-loop-smoke.ts b/packages/software-factory/scripts/smoke-tests/issue-loop-smoke.ts
index 79dcb63bf4..bbdb5f11df 100644
--- a/packages/software-factory/scripts/smoke-tests/issue-loop-smoke.ts
+++ b/packages/software-factory/scripts/smoke-tests/issue-loop-smoke.ts
@@ -271,7 +271,7 @@ async function scenarioSingleIssue(): Promise {
contextBuilder: new StubContextBuilder(),
tools: TOOLS,
issueStore: store,
- validator: new MockValidator([makePassingValidation()]),
+ createValidator: () => new MockValidator([makePassingValidation()]),
targetRealmUrl: 'https://example.test/target/',
});
@@ -328,10 +328,7 @@ async function scenarioDependencyCascade(): Promise {
contextBuilder: new StubContextBuilder(),
tools: TOOLS,
issueStore: store,
- validator: new MockValidator([
- makePassingValidation(),
- makePassingValidation(),
- ]),
+ createValidator: () => new MockValidator([makePassingValidation()]),
targetRealmUrl: 'https://example.test/target/',
});
@@ -402,11 +399,7 @@ async function scenarioPriorityOrdering(): Promise {
contextBuilder: new StubContextBuilder(),
tools: TOOLS,
issueStore: store,
- validator: new MockValidator([
- makePassingValidation(),
- makePassingValidation(),
- makePassingValidation(),
- ]),
+ createValidator: () => new MockValidator([makePassingValidation()]),
targetRealmUrl: 'https://example.test/target/',
});
@@ -461,7 +454,7 @@ async function scenarioMaxIterations(): Promise {
contextBuilder: new StubContextBuilder(),
tools: TOOLS,
issueStore: store,
- validator: new MockValidator(validations),
+ createValidator: () => new MockValidator(validations),
targetRealmUrl: 'https://example.test/target/',
maxIterationsPerIssue: 3,
});
@@ -507,7 +500,7 @@ async function scenarioBlockedIssue(): Promise {
contextBuilder: new StubContextBuilder(),
tools: TOOLS,
issueStore: store,
- validator: new MockValidator([makePassingValidation()]),
+ createValidator: () => new MockValidator([makePassingValidation()]),
targetRealmUrl: 'https://example.test/target/',
});
@@ -535,7 +528,7 @@ async function scenarioEmptyProject(): Promise {
contextBuilder: new StubContextBuilder(),
tools: TOOLS,
issueStore: store,
- validator: new NoOpValidator(),
+ createValidator: () => new NoOpValidator(),
targetRealmUrl: 'https://example.test/target/',
});
@@ -572,21 +565,23 @@ async function scenarioValidationPipeline(): Promise {
store,
);
- // Use a real ValidationPipeline with all NoOp steps (no server needed)
- let pipeline = new ValidationPipeline([
- new NoOpStepRunner('parse'),
- new NoOpStepRunner('lint'),
- new NoOpStepRunner('evaluate'),
- new NoOpStepRunner('instantiate'),
- new NoOpStepRunner('test'),
- ]);
+ // Use a real ValidationPipeline with all NoOp steps (no server needed).
+ // The factory creates a fresh pipeline per issue, as in production.
+ let createPipeline = () =>
+ new ValidationPipeline([
+ new NoOpStepRunner('parse'),
+ new NoOpStepRunner('lint'),
+ new NoOpStepRunner('evaluate'),
+ new NoOpStepRunner('instantiate'),
+ new NoOpStepRunner('test'),
+ ]);
let result = await runIssueLoop({
agent,
contextBuilder: new StubContextBuilder(),
tools: TOOLS,
issueStore: store,
- validator: pipeline,
+ createValidator: createPipeline,
targetRealmUrl: 'https://example.test/target/',
});
@@ -604,8 +599,9 @@ async function scenarioValidationPipeline(): Promise {
// Verify formatForContext works
let lastValidation = result.issueResults[0]?.lastValidation;
+ let pipelineForFormat = createPipeline();
let formatted = lastValidation
- ? pipeline.formatForContext(lastValidation)
+ ? pipelineForFormat.formatForContext(lastValidation)
: '';
check(
'formatForContext reports all passed',
diff --git a/packages/software-factory/src/issue-loop.ts b/packages/software-factory/src/issue-loop.ts
index 7052bfc0fb..426e505565 100644
--- a/packages/software-factory/src/issue-loop.ts
+++ b/packages/software-factory/src/issue-loop.ts
@@ -91,7 +91,12 @@ export interface IssueLoopConfig {
contextBuilder: IssueContextBuilderLike;
tools: FactoryTool[];
issueStore: IssueStore;
- validator: Validator;
+ /**
+ * Factory that creates a fresh Validator for each issue.
+ * Receives the issue ID so the validator can scope artifacts (e.g. TestRun
+ * slugs) to the specific issue being validated.
+ */
+ createValidator: (issueId: string) => Validator;
targetRealmUrl: string;
briefUrl?: string;
/** Maximum inner-loop iterations per issue. Default: 5. */
@@ -199,7 +204,7 @@ export async function runIssueLoop(
contextBuilder,
tools,
issueStore,
- validator,
+ createValidator,
targetRealmUrl,
briefUrl,
maxIterationsPerIssue = DEFAULT_MAX_ITERATIONS_PER_ISSUE,
@@ -254,6 +259,10 @@ export async function runIssueLoop(
`Outer cycle ${outerCycles}: picked issue ${issueSummaryLabel(issue)} (status=${issue.status}, priority=${issue.priority})`,
);
+ // Create a fresh validator scoped to this issue so that artifacts
+ // (e.g. TestRun cards) are named per-issue rather than shared.
+ let validator = createValidator(issue.id);
+
// -----------------------------------------------------------------------
// Inner loop: iterate on a single issue with validation
// -----------------------------------------------------------------------
diff --git a/packages/software-factory/src/test-run-execution.ts b/packages/software-factory/src/test-run-execution.ts
index ebc22f5427..306d5802e3 100644
--- a/packages/software-factory/src/test-run-execution.ts
+++ b/packages/software-factory/src/test-run-execution.ts
@@ -58,6 +58,7 @@ export async function resolveTestRun(
}
let sequenceNumber = await getNextSequenceNumber(
+ options.slug,
realmOptions,
options.lastSequenceNumber,
);
@@ -142,7 +143,13 @@ async function findResumableTestRun(
};
}
+/**
+ * Get the next sequence number for a given slug by searching existing
+ * TestRun cards in the realm and filtering by card ID. Each slug (issue)
+ * gets its own independent sequence starting from 1.
+ */
async function getNextSequenceNumber(
+ slug: string,
options: TestRunRealmOptions,
minSequenceNumber = 0,
): Promise {
@@ -153,18 +160,34 @@ async function getNextSequenceNumber(
on: { module: options.testResultsModuleUrl, name: 'TestRun' },
},
sort: [{ by: 'sequenceNumber', direction: 'desc' }],
- page: { size: 1 },
},
{ authorization: options.authorization, fetch: options.fetch },
);
- let latest = result?.ok
- ? (result.data?.[0] as
- | { attributes?: { sequenceNumber?: number } }
- | undefined)
- : undefined;
- let fromIndex = latest?.attributes?.sequenceNumber ?? 0;
- return Math.max(fromIndex, minSequenceNumber) + 1;
+ if (!result?.ok || !result.data) {
+ return minSequenceNumber + 1;
+ }
+
+ let targetRealmUrl = ensureTrailingSlash(options.targetRealmUrl);
+ let prefix = `Test Runs/${slug}-`;
+ let maxSeq = 0;
+
+ for (let card of result.data) {
+ let cardId = (card as { id?: string }).id ?? '';
+ let relativePath = cardId.startsWith(targetRealmUrl)
+ ? cardId.slice(targetRealmUrl.length)
+ : cardId;
+ if (relativePath.startsWith(prefix)) {
+ let attrs = (card as { attributes?: { sequenceNumber?: number } })
+ .attributes;
+ let seq = attrs?.sequenceNumber ?? 0;
+ if (seq > maxSeq) {
+ maxSeq = seq;
+ }
+ }
+ }
+
+ return Math.max(maxSeq, minSequenceNumber) + 1;
}
// ---------------------------------------------------------------------------
@@ -190,6 +213,8 @@ function buildQunitTestPageHtml(opts: {
targetRealmUrl: string;
/** Browser-accessible URL of the realm server (compat proxy) */
realmProxyUrl: string;
+ /** Optional slug identifying the issue under test — shown in the page title. */
+ slug?: string;
}): string {
let host = opts.assetServerUrl.replace(/\/$/, '');
// Ember config URLs must use the browser-accessible realm proxy,
@@ -268,7 +293,7 @@ function buildQunitTestPageHtml(opts: {
${metaTags.join('\n ')}
- Software Factory Card Tests
+ Software Factory Card Tests${opts.slug ? ` — ${opts.slug}` : ''}
${linkTags.join('\n ')}
@@ -466,6 +491,7 @@ export async function executeTestRunFromRealm(
hostDistDir,
targetRealmUrl: options.targetRealmUrl,
realmProxyUrl: options.hostAppUrl,
+ slug: options.slug,
});
setHtml(html);
diff --git a/packages/software-factory/src/validators/test-step.ts b/packages/software-factory/src/validators/test-step.ts
index a9c8e9b87f..e1ae9f613e 100644
--- a/packages/software-factory/src/validators/test-step.ts
+++ b/packages/software-factory/src/validators/test-step.ts
@@ -138,6 +138,12 @@ export class TestValidationStep implements ValidationStepRunner {
? deriveIssueSlug(this.config.issueId)
: 'validation';
+ // Build the issue card URL for the TestRun → Issue linksTo relationship.
+ // issueId is a realm-relative path like "Issues/sticky-note-define-core".
+ let issueURL = this.config.issueId
+ ? new URL(this.config.issueId, targetRealmUrl).href
+ : undefined;
+
handle = await this.executeTestRunFn({
targetRealmUrl,
testResultsModuleUrl: this.config.testResultsModuleUrl,
@@ -149,6 +155,7 @@ export class TestValidationStep implements ValidationStepRunner {
hostAppUrl: this.config.hostAppUrl,
forceNew: true,
lastSequenceNumber: this.lastSequenceNumber,
+ issueURL,
});
if (handle.sequenceNumber != null) {
diff --git a/packages/software-factory/tests/factory-test-realm.test.ts b/packages/software-factory/tests/factory-test-realm.test.ts
index e311ccc395..1fe682b96e 100644
--- a/packages/software-factory/tests/factory-test-realm.test.ts
+++ b/packages/software-factory/tests/factory-test-realm.test.ts
@@ -492,7 +492,7 @@ module('factory-test-realm > resolveTestRun', function () {
let urlStr = String(url);
let method = init?.method ?? 'GET';
- // Search endpoint
+ // Search endpoint (used by findResumableTestRun and getNextSequenceNumber)
if (urlStr.includes('_search') && method === 'QUERY') {
return new Response(
JSON.stringify({
diff --git a/packages/software-factory/tests/issue-loop.test.ts b/packages/software-factory/tests/issue-loop.test.ts
index b5f1f72336..59a406907d 100644
--- a/packages/software-factory/tests/issue-loop.test.ts
+++ b/packages/software-factory/tests/issue-loop.test.ts
@@ -238,7 +238,7 @@ function makeLoopConfig(
return {
contextBuilder: new StubIssueContextBuilder(),
tools: DEFAULT_TOOLS,
- validator: new MockValidator([makePassingValidation()]),
+ createValidator: () => new MockValidator([makePassingValidation()]),
targetRealmUrl: 'https://example.test/target/',
maxIterationsPerIssue: 5,
maxOuterCycles: 50,
@@ -272,7 +272,7 @@ module('issue-loop > happy path', function () {
makeLoopConfig({
agent,
issueStore: store,
- validator: new MockValidator([makePassingValidation()]),
+ createValidator: () => new MockValidator([makePassingValidation()]),
}),
);
@@ -324,10 +324,7 @@ module('issue-loop > multiple issues', function () {
makeLoopConfig({
agent,
issueStore: store,
- validator: new MockValidator([
- makePassingValidation(),
- makePassingValidation(),
- ]),
+ createValidator: () => new MockValidator([makePassingValidation()]),
}),
);
@@ -379,10 +376,8 @@ module('issue-loop > validation failure', function () {
agent,
issueStore: store,
contextBuilder,
- validator: new MockValidator([
- makeFailingValidation(),
- makePassingValidation(),
- ]),
+ createValidator: () =>
+ new MockValidator([makeFailingValidation(), makePassingValidation()]),
}),
);
@@ -436,10 +431,7 @@ module('issue-loop > blocked issue', function () {
makeLoopConfig({
agent,
issueStore: store,
- validator: new MockValidator([
- makePassingValidation(),
- makePassingValidation(),
- ]),
+ createValidator: () => new MockValidator([makePassingValidation()]),
}),
);
@@ -484,7 +476,7 @@ module('issue-loop > max inner iterations', function () {
makeLoopConfig({
agent,
issueStore: store,
- validator: new MockValidator(validations),
+ createValidator: () => new MockValidator(validations),
maxIterationsPerIssue: 3,
}),
);
@@ -524,7 +516,7 @@ module('issue-loop > max inner iterations', function () {
makeLoopConfig({
agent,
issueStore: store,
- validator: new MockValidator(validations),
+ createValidator: () => new MockValidator(validations),
maxIterationsPerIssue: 3,
}),
);
@@ -557,7 +549,7 @@ module('issue-loop > max inner iterations', function () {
makeLoopConfig({
agent,
issueStore: store,
- validator: new MockValidator(validations),
+ createValidator: () => new MockValidator(validations),
maxIterationsPerIssue: 2,
}),
);
@@ -653,7 +645,7 @@ module('issue-loop > NoOpValidator', function () {
makeLoopConfig({
agent,
issueStore: store,
- validator: new NoOpValidator(),
+ createValidator: () => new NoOpValidator(),
briefUrl: 'https://example.test/brief/',
}),
);
@@ -703,7 +695,8 @@ module('issue-loop > context threading', function () {
agent,
issueStore: store,
contextBuilder,
- validator: new MockValidator([failValidation, makePassingValidation()]),
+ createValidator: () =>
+ new MockValidator([failValidation, makePassingValidation()]),
}),
);
@@ -749,7 +742,7 @@ module('issue-loop > brief URL threading', function () {
agent,
issueStore: store,
contextBuilder,
- validator: new MockValidator([makePassingValidation()]),
+ createValidator: () => new MockValidator([makePassingValidation()]),
briefUrl: 'https://example.test/brief/',
}),
);
@@ -822,10 +815,7 @@ module('issue-loop > new issues mid-loop', function () {
makeLoopConfig({
agent,
issueStore: store,
- validator: new MockValidator([
- makePassingValidation(),
- makePassingValidation(),
- ]),
+ createValidator: () => new MockValidator([makePassingValidation()]),
}),
);
@@ -835,3 +825,70 @@ module('issue-loop > new issues mid-loop', function () {
assert.strictEqual(result.issueResults[1].issueId, 'new-1');
});
});
+
+// ---------------------------------------------------------------------------
+// 12. createValidator receives issue ID
+// ---------------------------------------------------------------------------
+
+module('issue-loop > createValidator receives issue ID', function () {
+ test('createValidator is called with the current issue ID', async function (assert) {
+ let store = new MockIssueStore([
+ makeIssue({
+ id: 'Issues/sticky-note-define-core',
+ status: 'backlog',
+ priority: 'high',
+ order: 1,
+ }),
+ makeIssue({
+ id: 'Issues/sticky-note-catalog-spec',
+ status: 'backlog',
+ priority: 'medium',
+ order: 2,
+ }),
+ ]);
+
+ let agent = new MockLoopAgent(
+ [
+ {
+ toolCalls: [
+ { tool: 'write_file', args: { path: 'a.gts', content: '' } },
+ ],
+ updateIssue: {
+ id: 'Issues/sticky-note-define-core',
+ status: 'done',
+ },
+ },
+ {
+ toolCalls: [
+ { tool: 'write_file', args: { path: 'b.gts', content: '' } },
+ ],
+ updateIssue: {
+ id: 'Issues/sticky-note-catalog-spec',
+ status: 'done',
+ },
+ },
+ ],
+ store,
+ );
+
+ let receivedIssueIds: string[] = [];
+
+ let result = await runIssueLoop(
+ makeLoopConfig({
+ agent,
+ issueStore: store,
+ createValidator: (issueId: string) => {
+ receivedIssueIds.push(issueId);
+ return new MockValidator([makePassingValidation()]);
+ },
+ }),
+ );
+
+ assert.strictEqual(result.outcome, 'all_issues_done');
+ assert.deepEqual(
+ receivedIssueIds,
+ ['Issues/sticky-note-define-core', 'Issues/sticky-note-catalog-spec'],
+ 'createValidator received the correct issue IDs in order',
+ );
+ });
+});